
# Copyright (C) 2026 [Massimo Sabattini]
# *
# * Questo programma è software libero; puoi ridistribuirlo e/o modificarlo
# * secondo i termini della GNU General Public License come pubblicato dalla
# * Free Software Foundation; o versione 3 della Licenza, o (a tua scelta)
# * qualsiasi versione successiva.
# *
# * Questo programma è distribuito nella speranza che sia utile,
# * ma SENZA ALCUNA GARANZIA; senza neanche la garanzia implicita di
# * COMMERCIABILITÀ o IDONEITÀ PER UN PARTICOLARE SCOPO.
# * Vedi la GNU General Public License per maggiori dettagli.
# *
# * Dovresti aver ricevuto una copia della GNU General Public License
# * insieme a questo programma; se non l'hai ricevuta, vedi <https://www.gnu.org/licenses>.
# *
import os, sys, json, re
from datetime import datetime
from PySide6.QtCore import QSettings, QUrl
from PySide6.QtWidgets import QListWidget, QListWidgetItem
from PySide6.QtWidgets import QAbstractItemView
from PySide6.QtWidgets import QSizePolicy
from PySide6.QtGui import QKeySequence, QColor, QFont, QAction, QDesktopServices
from PySide6.QtPrintSupport import QPrinter
from PySide6.QtCore import QPoint
from PySide6.QtGui import QPainter
from PySide6.QtWidgets import QWidget
from PySide6.QtGui import QShortcut
from PySide6.QtCore import QRect
from PySide6.QtCore import Qt, QTimer, QLocale
from PySide6.QtCore import QEvent, QObject
os.environ["QT_LOGGING_RULES"] = "qt.gui.imageio.*=false"
from PySide6.QtGui import QPixmap, QFontMetrics
from functools import partial
from PySide6.QtWidgets import QAbstractSpinBox, QGridLayout
from PySide6.QtWidgets import (
    QApplication, QLabel, QLineEdit, QTabWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QDoubleSpinBox, QSpinBox, QFrame, QFileDialog, QMessageBox,
    QMenuBar, QMenu
)

APP_TITLE = "RB RANKING"
BYLINE = "by MAX&ChatGPT"
APP_VERSION = "2.0.3"                           # RICORDATI DI CAMBIARE IL NUMERO DI VERSIONE DI OGNI RELEASE"

def slugify(s: str) -> str:
    s = s.strip().lower()
    s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
    return s or "rb-ranking"

def db_path_from_title(title: str) -> str:
    base = slugify(title) + ".rbr.json"
    return os.path.join(os.path.dirname(__file__), base)

def save_db_atomic(db: dict, path: str):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(db, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)

    # copia legacy: nomeranking.json nella stessa cartella
    try:
        legacy = os.path.join(os.path.dirname(path), "nomeranking.json")
        tmp2 = legacy + ".tmp"
        with open(tmp2, "w", encoding="utf-8") as f2:
            json.dump(db, f2, ensure_ascii=False, indent=2)
        os.replace(tmp2, legacy)
    except Exception:
        pass

def parse_meta_line(line: str) -> dict:
    s = line.strip()
    if not s:
        raise ValueError("META vuoto")

    # 1) prova split (spazio/virgola)
    parts = [p for p in re.split(r"[,\s]+", s) if p]
    if len(parts) == 11:
        vals = parts
    else:
        # 2) fallback: formato a larghezza fissa dal QR (3,6,6,6,4,4,4,4,4,5,3) = 49 char
        widths = [3,6,6,6,4,4,4,4,4,5,3]
        if len(s) != sum(widths):
            raise ValueError(f"META incompleto: attesi 49 char, ricevuti {len(s)}")
        vals, i = [], 0
        for w in widths:
            vals.append(s[i:i+w]); i += w

    keys = ["team","data","ora_P","ora_A","punti_T","P_skip","P_extra","P_CAP","PSpeed","km_RB","AVG"]
    rec = dict(zip(keys, vals))

    # normalizza
    for k in ["punti_T","P_skip","P_extra","P_CAP","PSpeed","km_RB","AVG"]:
        rec[k] = int(rec[k])
    rec["team"] = rec["team"].lstrip("0") or "0"
    return rec

class DotToDecimalFilter(QObject):
    def __init__(self, le: QLineEdit, decimal_char: str, parent=None):
        super().__init__(parent)
        self.le = le
        self.dec = decimal_char

    def eventFilter(self, obj, event):
        if obj is self.le and event.type() == QEvent.KeyPress:
            if event.text() == '.':
                pos = self.le.cursorPosition()
                t = self.le.text()
                self.le.setText(t[:pos] + self.dec + t[pos:])
                self.le.setCursorPosition(pos + 1)
                return True
        return False

# --------- piccoli widget riutilizzabili ---------
class MultiplierControl(QWidget):
    """Spin +/− con passo 0.25 e limiti 0.25..1.75"""
    def __init__(self, value=1.0, parent=None):
        super().__init__(parent)
        self.spin = QDoubleSpinBox()
        self.spin.setDecimals(2)
        self.spin.setSingleStep(0.25)
        self.spin.setRange(0.25, 1.75)
        self.spin.setValue(float(value))
        self.spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
        self.spin.setFixedWidth(80)
        self.spin.setAlignment(Qt.AlignCenter)

        # accetta "." come separatore decimale (pre-validazione)
        dec = QLocale().decimalPoint()
        self._dotfilter = DotToDecimalFilter(self.spin.lineEdit(), dec, self)
        self.spin.lineEdit().installEventFilter(self._dotfilter)

        self.btn_minus = QPushButton("-")
        self.btn_plus  = QPushButton("+")
        for b in (self.btn_minus, self.btn_plus):
            b.setObjectName("pmBtn")
            b.setFixedSize(32, 32) 

        self.btn_minus.clicked.connect(lambda: self.spin.stepBy(-1))
        self.btn_plus.clicked.connect(lambda: self.spin.stepBy(+1))

        lay = QHBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(6)
        lay.addWidget(self.btn_minus)
        lay.addWidget(self.spin)
        lay.addWidget(self.btn_plus)

    def value(self) -> float:
        return float(self.spin.value())
    def setValue(self, value: float):
        self.spin.setValue(float(value))


class ToggleCard(QWidget):
    """Bottone grande + opzionale controllo (moltiplicatore o campo km/h)"""
    def __init__(self, title: str, kind: str, parent=None):
        """
        kind: 'mult' per moltiplicatore 0.25..1.75
              'regularity' per km/h (max 30)
              'none' nessun campo
        """
        super().__init__(parent)
        self.setObjectName("card")
        self.button = QPushButton(title)
        self.button.setObjectName("toggleBtn")
        self.button.setCheckable(True)
        self.button.setMinimumSize(160, 150)   # min width 160, altezza 150
        self.button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

        # campo sotto al bottone
        self.control = None
        if kind == "mult":
            self.control = MultiplierControl(1.0)
        elif kind == "regularity":
            sp = QDoubleSpinBox()
            sp.setSuffix(" km/h")
            sp.setDecimals(2)
            sp.setRange(0.00, 30.00)   # limite 30 km/h
            sp.setSingleStep(0.25)
            sp.setButtonSymbols(QDoubleSpinBox.NoButtons)
            sp.setValue(24.50)

            # accetta "." come separatore decimale (pre-validazione)
            dec = QLocale().decimalPoint()
            self._dotfilter_reg = DotToDecimalFilter(sp.lineEdit(), dec, self)
            sp.lineEdit().installEventFilter(self._dotfilter_reg)

            sp.setFixedWidth(120)
            self.control = sp

        lay = QVBoxLayout(self)
        lay.setContentsMargins(8, 8, 8, 8)
        lay.setSpacing(6)
        lay.addWidget(self.button, alignment=Qt.AlignHCenter)      # centrato
        if self.control:
            lay.addWidget(self.control, alignment=Qt.AlignHCenter)  # centrato

        QTimer.singleShot(0, self._fit_button_font)  # primo fit dopo il show

    def _fit_button_font(self):
        btn = self.button
        if not btn.text():
            return
        # spazio utile dentro il bottone
        w = max(10, btn.width() - 24)
        h = max(10, btn.height() - 24)

        lo, hi = 12, max(22, int(h * 0.45))  # es. min 12pt, max ~45% altezza

        best = lo
        while lo <= hi:
            mid = (lo + hi) // 2
            f = btn.font()
            f.setBold(True)
            f.setPointSize(mid)
            fm = QFontMetrics(f)
            tw = fm.horizontalAdvance(btn.text())
            th = fm.height()
            if tw <= w and th <= h:
                best = mid
                lo = mid + 1
            else:
                hi = mid - 1
        f = btn.font()
        f.setBold(True)
        f.setPointSize(best)
        btn.setFont(f)

    def resizeEvent(self, e):
        super().resizeEvent(e)
        self._fit_button_font()

class Header(QWidget):
    """Header nero con immagine, titoli e contatore a destra"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setObjectName("header")
        left = QHBoxLayout()
        # immagine
        img_label = QLabel()
        img_label.setObjectName("logo")
        img_path = os.path.join(os.path.dirname(__file__), "RB Ranking.png")
        if os.path.exists(img_path):
            pm = QPixmap(img_path).scaledToHeight(48, Qt.SmoothTransformation)
            img_label.setPixmap(pm)
        img_label.setFixedHeight(52)

        # titoli
        title1 = QLabel(f"{APP_TITLE}")
        title1.setObjectName("appTitle")
        title2 = QLabel(BYLINE)
        title2.setObjectName("byline")

        titles = QVBoxLayout()
        titles.setSpacing(0)
        titles.addWidget(title1)
        titles.addWidget(title2)

        left.addWidget(img_label)
        left.addLayout(titles)
        left.addStretch(1)

        # contatore a destra (tot arrivati)
        self.counter = QLineEdit("000")
        self.counter.setAlignment(Qt.AlignCenter)
        self.counter.setReadOnly(True)
        self.counter.setFixedSize(96, 52)
        self.counter.setObjectName("counter")
        self.open_btn = QPushButton("OPEN…")
        self.open_btn.setObjectName("hdrBtn")
        self.open_btn.setFixedHeight(28)
        self.open_btn.hide()  # OPEN ora nel menu File

        self.new_btn = QPushButton("Nuovo")
        self.new_btn.setObjectName("hdrBtn")
        self.new_btn.setFixedHeight(28)
        self.new_btn.hide()
        self.new_btn.setEnabled(False)

        # riga titolo raduno sotto (editabile)
        self.event_title = QLineEdit()
        self.event_title.setPlaceholderText("Titolo del raduno…")
        self.event_title.setObjectName("eventTitle")
        self.event_title.setAlignment(Qt.AlignCenter)
        self.event_title.setFixedHeight(56)                  # ~2x
        self.event_title.setPlaceholderText("TITLE")
        self.event_title.setProperty("required", True)       # evidenzia finché vuoto/non confermato
        self.event_title.setAlignment(Qt.AlignCenter)

        # compongo
        top = QHBoxLayout()
        top.addLayout(left, 1)

        # --- DATA EVENTO (centrata tra titolo e contatore) ---
        mid = QVBoxLayout(); mid.setSpacing(4)
        dlabel = QLabel("DATE"); dlabel.setObjectName("trialLabel"); dlabel.setAlignment(Qt.AlignCenter)

        self.event_date = QLineEdit()
        self.event_date.setObjectName("eventDate")
        self.event_date.setAlignment(Qt.AlignCenter)
        self.event_date.setFixedWidth(160)
        self.event_date.setInputMask("99/99/99")   # DD/MM/YY
        self.event_date.setPlaceholderText("DD/MM/YY")
        self.event_date.setProperty("required", True)

        mid.addWidget(dlabel, 0, Qt.AlignCenter)
        mid.addWidget(self.event_date, 0, Qt.AlignCenter)

        top.addLayout(mid)   # <-- INSERISCI QUESTO tra left e right

        right = QVBoxLayout()
        right.setSpacing(6)
        right.addWidget(self.counter, 0, Qt.AlignRight)

        btns = QHBoxLayout()
        btns.setSpacing(6)
        btns.addWidget(self.open_btn)
        btns.addWidget(self.new_btn)
        right.addLayout(btns)

        top.addLayout(right)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(12, 12, 12, 0)
        layout.addLayout(top)
        layout.addWidget(self.event_title)

class ScanPane(QWidget):
    """Tab SCAN: input dallo scanner (invio), preview META, contatore e FINISH"""
    def __init__(self, on_scan_cb, parent=None):
        super().__init__(parent)
        self.on_scan_cb = on_scan_cb

        self.scan_input = QLineEdit()
        self.scan_input.setPlaceholderText("SCAN THE QR")
        self.scan_input.setObjectName("scanInput")

        self.meta_box = QLineEdit()
        self.meta_box.setPlaceholderText("Preview META…")
        self.meta_box.setReadOnly(True)
        self.meta_box.setObjectName("metaBox")

        self.scanned_label = QLabel("crew scanned 0")
        self.scanned_label.setObjectName("scannedLabel")
        self.scanned_label.setAlignment(Qt.AlignCenter)
        self.scanned_label.setMinimumHeight(120)

        self.finish_btn = QPushButton("FINISH")
        self.finish_btn.setObjectName("finishBtn")
        self.finish_btn.setMinimumSize(220, 80)

        v = QVBoxLayout(self)
        v.setContentsMargins(24, 24, 24, 24)
        v.setSpacing(16)
        v.addWidget(self.scan_input)
        v.addWidget(self.meta_box)
        v.addWidget(self.scanned_label, 1)
        v.addWidget(self.finish_btn, 0, Qt.AlignHCenter)

        self.scan_input.returnPressed.connect(self._do_scan)

    def _do_scan(self):
        txt = self.scan_input.text()
        if not txt.strip():
            return
        self.meta_box.setText(txt.strip())
        self.on_scan_cb(txt.strip())
        self.scan_input.clear()
        self.scan_input.setFocus()

    def set_scanned_count(self, n: int):
        self.scanned_label.setText(f"crew scanned {n}")
        self.finish_btn.setEnabled(n > 0)

class TrialPage(QWidget):
    def __init__(self, on_team_change, on_row_update, parent=None):
        NAME_W = 120   # larghezza campo "N Obst." (è quella che usi già per name.setFixedWidth)
        INDEX_W = 32   # larghezza fissa per la colonna con l'indice (1., 2., ...)
        super().__init__(parent)
        self.on_team_change = on_team_change      # callback: cambio team / setup
        self.on_row_update = on_row_update        # callback: risultato riga
        self._active_row = None
        self._loading = False
        self.n_obst = 9

        # intestazione punti tentativi
        self.hdr = QHBoxLayout()
        self.hdr.setSpacing(4)

        # spacer a sinistra che useremo per l'allineamento
        self._hdr_left_spacer = QWidget()
        self._hdr_left_spacer.setFixedWidth(0)
        self.hdr.insertWidget(0, self._hdr_left_spacer)

        self.hdr.setContentsMargins(0, 0, 0, 0)
        self.p1 = QSpinBox(); self.p1.setRange(0, 999); self.p1.setValue(100)

        self.p2 = QSpinBox(); self.p2.setRange(0, 999); self.p2.setValue(60)
        self.p3 = QSpinBox(); self.p3.setRange(0, 999); self.p3.setValue(20)
        for sp in (self.p1, self.p2, self.p3):
            sp.setAlignment(Qt.AlignCenter); sp.setFixedWidth(72)

        for txt, sp in [
            ("Points 1º attempt", self.p1),
            ("Points 2º attempt", self.p2),
            ("Points 3º attempt", self.p3),
        ]:
            col = QVBoxLayout(); col.setSpacing(4)
            lbl = QLabel(txt); lbl.setObjectName("trialLabel")
            lbl.setAlignment(Qt.AlignCenter)
            lbl.setWordWrap(False)                     # << era True
            col.addWidget(lbl)
            sp.setObjectName("hdrSpin")
            sp.setButtonSymbols(QAbstractSpinBox.NoButtons)
            sp.setFixedWidth(112)                      # << più largo
            col.addWidget(sp, alignment=Qt.AlignCenter)
            wrap = QWidget(); wrap.setLayout(col)
            wrap.setFixedWidth(180)                    # << evita tagli a destra
            self.hdr.addWidget(wrap)

        # --- header penalties (una sola volta, a destra degli attempts) ---
        self.kd_penalty = QSpinBox(); self.kd_penalty.setRange(0, 50); self.kd_penalty.setValue(1)
        self.rv_penalty = QSpinBox(); self.rv_penalty.setRange(0, 50); self.rv_penalty.setValue(3)

        for sp in (self.kd_penalty, self.rv_penalty):
            sp.setObjectName("hdrSpin")
            sp.setButtonSymbols(QAbstractSpinBox.NoButtons)
            sp.setAlignment(Qt.AlignCenter)
            sp.setFixedWidth(112)                      # << più largo

            self._load_setup()  # ripristina valori se presenti
            for sp in (self.p1, self.p2, self.p3, self.kd_penalty, self.rv_penalty):
                sp.valueChanged.connect(self._save_setup)

        col_kd = QVBoxLayout(); col_kd.setSpacing(4)
        lbl_kd = QLabel("knocked down"); lbl_kd.setObjectName("trialLabel"); lbl_kd.setAlignment(Qt.AlignCenter)
        lbl_kd.setWordWrap(True)
        col_kd.addWidget(lbl_kd); col_kd.addWidget(self.kd_penalty, alignment=Qt.AlignCenter)

        col_rv = QVBoxLayout(); col_rv.setSpacing(4)
        lbl_rv = QLabel("reverse"); lbl_rv.setObjectName("trialLabel"); lbl_rv.setAlignment(Qt.AlignCenter)
        lbl_rv.setWordWrap(True)
        col_rv.addWidget(lbl_rv); col_rv.addWidget(self.rv_penalty, alignment=Qt.AlignCenter)

        wrap_kd = QWidget(); wrap_kd.setLayout(col_kd); wrap_kd.setFixedWidth(140)   # crea e poi imposta larghezza
        wrap_rv = QWidget(); wrap_rv.setLayout(col_rv); wrap_rv.setFixedWidth(140)

        self.hdr.addWidget(wrap_kd)
        self.hdr.addSpacing(16)
        self.hdr.addWidget(wrap_rv)

        # === NUOVO: wrappa l'header in un QWidget e azzera i margini ===
        self.hdr.setContentsMargins(0, 0, 0, 0)
        self.hdr_wrap = QWidget()
        self.hdr_wrap.setLayout(self.hdr)
        # ---- HEADER COLONNE (visibile) ----
        def _mk_hdr(txt, w):
            l = QLabel(txt)
            l.setFixedWidth(w)
            l.setAlignment(Qt.AlignCenter)
            l.setStyleSheet("color:#ddd; font: 10pt 'Arial';")
            return l

        ATT_W = 120  # deve combaciare con wrap.setFixedWidth(120) delle righe

        self.colhdr = QHBoxLayout()
        self.colhdr.setSpacing(4)
        self.colhdr.setContentsMargins(0, 0, 0, 0)

        self.colhdr.addWidget(_mk_hdr("", INDEX_W))  # colonna indice (vuota)
        self.colhdr_name = _mk_hdr("obst.name", NAME_W)
        self.colhdr.addWidget(self.colhdr_name)
        self.colhdr.addSpacing(15)

        self.colhdr.addWidget(_mk_hdr("1ª attempt", ATT_W))
        self.colhdr.addWidget(_mk_hdr("2ª attempt", ATT_W))
        self.colhdr.addWidget(_mk_hdr("3ª attempt", ATT_W))

        self.colhdr.addSpacing(25)
        self.colhdr.addWidget(_mk_hdr("knockedDown", 80))
        self.colhdr.addSpacing(65)
        self.colhdr.addWidget(_mk_hdr("Reverse", 80))
        self.colhdr.addStretch(1)

        self.colhdr_wrap = QWidget()
        self.colhdr_wrap.setLayout(self.colhdr)
        # (poi lascia il tuo hdr.addStretch(1) già presente)

        # 9 righe: nome prova + placeholder punti
        self.rows = []
        grid = QVBoxLayout(); grid.setSpacing(8)
        for i in range(9):
            h = QHBoxLayout(); h.setSpacing(4)

            name = QLineEdit(f"{i+1} Obst.")
            name.setMaxLength(12)
            name.setFixedWidth(NAME_W)

            name.setObjectName("obstName")
            name.setFixedHeight(34)
            name.setAlignment(Qt.AlignCenter)
            name.setReadOnly(True)
            name.editingFinished.connect(self._setup_changed)
            name.setFocusPolicy(Qt.NoFocus)
            name.setCursor(Qt.ArrowCursor)
            name.setContextMenuPolicy(Qt.NoContextMenu)

            # clic sul nome -> sblocco sequenziale della riga (TRIAL)
            orig_press = name.mousePressEvent
            def _mk_press(idx, orig):
                def handler(ev):
                    self._on_row_clicked(idx)
                    return orig(ev)
                return handler
            name.mousePressEvent = _mk_press(i, orig_press)

            pts  = QLabel("0"); pts.setFixedWidth(50); pts.setAlignment(Qt.AlignCenter)

            # --- 3 tentativi: rosso (fail) + verde (pass) ---
            def mkbtn(role: str):
                b = QPushButton()
                b.setFixedSize(56, 28)
                b.setCheckable(True)
                b.setAutoExclusive(False)
                b.setObjectName("attemptPass" if role == "pass" else "attemptFail")
                return b

            fail1 = mkbtn("fail");  pass1 = mkbtn("pass")
            fail2 = mkbtn("fail");  pass2 = mkbtn("pass")
            fail3 = mkbtn("fail");  pass3 = mkbtn("pass")

            # (opzionale ma utile per debug/logic)
            for idx, (f, p) in enumerate([(fail1, pass1), (fail2, pass2), (fail3, pass3)], start=1):
                f.setProperty("role", "fail");  p.setProperty("role", "pass")
                f.setProperty("attempt", idx);  p.setProperty("attempt", idx)
                f.setProperty("row", i);        p.setProperty("row", i)

            # --- toggle setup: checkable + stile + ruolo (fail/pass)
            for btn, role in [(fail1, "fail"), (pass1, "pass"),
                            (fail2, "fail"), (pass2, "pass"),
                            (fail3, "fail"), (pass3, "pass")]:
                btn.setCheckable(True)
                btn.setObjectName("attemptBtn")
                btn.setProperty("role", role)
                btn.style().unpolish(btn); btn.style().polish(btn); btn.update()

            # wiring con indice di riga i
            fail1.clicked.connect(partial(self._toggle_attempt, i, 1, "fail"))
            pass1.clicked.connect(partial(self._toggle_attempt, i, 1, "pass"))
            fail2.clicked.connect(partial(self._toggle_attempt, i, 2, "fail"))
            pass2.clicked.connect(partial(self._toggle_attempt, i, 2, "pass"))
            fail3.clicked.connect(partial(self._toggle_attempt, i, 3, "fail"))
            pass3.clicked.connect(partial(self._toggle_attempt, i, 3, "pass"))

            idx = QLabel(f"{i+1}")
            idx.setFixedWidth(INDEX_W)
            idx.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
            h.addWidget(idx)

            h.addWidget(name)
            h.addSpacing(2)   # avvicina bottoni e contatori al campo ostacolo

            # se non l'hai già fatto: aggiungi i 3 gruppi di bottoni al layout
            for pair in [(fail1, pass1), (fail2, pass2), (fail3, pass3)]:
                box = QHBoxLayout(); box.setSpacing(2)
                box.addWidget(pair[0]); box.addWidget(pair[1])
                wrap = QWidget(); wrap.setLayout(box)
                wrap.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)   # << fisso, non si espande
                h.addWidget(wrap)
                wrap.setFixedWidth(120)                       # <— stessa larghezza dell’header tentativi

            # --- CONTATORI RIGA: paletti e retromarcia ---
            h.addSpacing(6)
            kd_count = QSpinBox(); kd_count.setRange(0, 20); kd_count.setFixedWidth(80); kd_count.setAlignment(Qt.AlignCenter)
            rv_count = QSpinBox(); rv_count.setRange(0, 20); rv_count.setFixedWidth(80); rv_count.setAlignment(Qt.AlignCenter)

            # ⬇︎ aggiungi queste due righe
            kd_count.valueChanged.connect(lambda _v, idx=i: self._recompute_row_points(idx))
            rv_count.valueChanged.connect(lambda _v, idx=i: self._recompute_row_points(idx))

            h.addWidget(kd_count)
            h.addSpacing(16)        # buco tra paletti e reverse
            h.addWidget(rv_count)
            h.addWidget(pts)
            h.addStretch(1)   # << spinge tutto il resto a sinistra

            w = QWidget(); w.setLayout(h)
            grid.addWidget(w)

            # stato riga
            row = {
                "name": name,
                "pts_lbl": pts,
                "buttons": [(fail1,pass1),(fail2,pass2),(fail3,pass3)],
                "kd": kd_count,
                "rv": rv_count,
                "base": 0,       # punti di base dal tentativo superato (100/60/20), prima delle penalità
                "next": 1,
                "locked": False
            }
            self.rows.append(row) 

        self.total_label = QLabel("Totale trial: 0")
        self.total_label.setAlignment(Qt.AlignRight)

        # ----- COLONNA DESTRA (quello che avevi già) -----
        right_col = QVBoxLayout()
        right_col.setContentsMargins(24,4,24,24)
        right_col.setSpacing(16)

        right_col.addWidget(self.hdr_wrap)
        right_col.setAlignment(self.hdr_wrap, Qt.AlignLeft)
        self.hdr_wrap.setVisible(False)              # nasconde punti tentativi + penalità
        right_col.addWidget(self.colhdr_wrap)        # mostra intestazioni colonne
        right_col.setAlignment(self.colhdr_wrap, Qt.AlignLeft)

        right_col.addSpacing(6)
        sep = QFrame(); sep.setFrameShape(QFrame.HLine); sep.setFrameShadow(QFrame.Plain)
        sep.setFixedHeight(2); sep.setStyleSheet("background:#1db954;")  # verde (cambia a #ff3b30 se la vuoi rossa)
        right_col.addWidget(sep)
        right_col.addSpacing(10)

        right_col.addSpacing(8)
        right_col.addLayout(grid)
        right_col.addStretch(1)
        right_col.addWidget(self.total_label)

        right_widget = QWidget()
        right_widget.setLayout(right_col)


        # ----- COLONNA SINISTRA (nuova sidebar) -----
        left_col = QVBoxLayout()
        left_col.setContentsMargins(24,24,24,24)
        left_col.setSpacing(24)

        # testo rosso "check the points!" 
        hint = QLabel("TRIAL OFF ROAD")
        hint.setObjectName("trialHint")

        # box grande TEAM
        self.team_big = QLineEdit()
        self.team_big.setObjectName("trialTeamBox")
        self.team_big.setAlignment(Qt.AlignCenter)
        self.team_big.setFixedSize(220, 80)
        self.team_edit = self.team_big   # alias per riusare la logica esistente
        self._header_locked = False
        self.is_team_completed = None  # callback impostata dalla MainWindow
        self._current_team = None
        self._team_dup_css = "background-color:#ffa500; color:#000000;"

        # box grande POINTS (read only per ora) Sempre READ ONLY
        self.total_big = QLineEdit("points")
        self.total_big.setObjectName("trialTotalBox")
        self.total_big.setAlignment(Qt.AlignCenter)
        self.total_big.setReadOnly(True)
        self.total_big.setFixedSize(220, 80)

        # bottone OK TEAM
        self.ok_team_btn = QPushButton("OK TEAM")
        self.ok_team_btn.setObjectName("trialOkTeamBtn")
        self.ok_team_btn.setFixedSize(220, 60)
        self.ok_team_btn.setEnabled(False)

        # bottone FINISH (solo grafica, logica dopo)
        self.finish_trial_btn = QPushButton("FINISH")
        self.finish_trial_btn.setObjectName("trialFinishBtn")
        self.finish_trial_btn.setFixedSize(220, 60)
        self.finish_trial_btn.setEnabled(False)

        # titolo rosso in alto
        left_col.addWidget(hint, alignment=Qt.AlignLeft)
        left_col.addSpacing(12)

        # centra TEAM/points verticalmente tra hint e FINISH
        left_col.addStretch(1)
        left_col.addWidget(self.team_big,  alignment=Qt.AlignHCenter)
        left_col.addSpacing(12)
        left_col.addWidget(self.total_big, alignment=Qt.AlignHCenter)
        left_col.addSpacing(8)
        left_col.addWidget(self.ok_team_btn, alignment=Qt.AlignHCenter)
        left_col.addStretch(1)  # ← lasciala
        left_col.addWidget(self.finish_trial_btn, alignment=Qt.AlignHCenter)

        # FINISH allineato al fondo (e centrato orizzontalmente)
        left_col.addWidget(self.finish_trial_btn, alignment=Qt.AlignHCenter)


        left_col.addStretch(1)

        # FINISH allineato al fondo (e centrato orizzontalmente)
        left_col.addWidget(self.finish_trial_btn, alignment=Qt.AlignHCenter)
        left_col.addSpacing(40)  # alza il bottone creando un margine fisso dal fondo

        left_widget = QWidget()
        left_widget.setLayout(left_col)

        # ----- LAYOUT FINALE ORIZZONTALE (sidebar + contenuto) -----
        main_row = QHBoxLayout(self)
        main_row.setContentsMargins(24,24,24,24)
        main_row.setSpacing(40)

        main_row.addWidget(left_widget, 0)   # colonna sinistra
        main_row.addWidget(right_widget, 1)  # colonna destra (si allarga)

        # --- colonna destra: SCANNED TEAMS ---
        teams_col = QVBoxLayout(); teams_col.setSpacing(6)

        lab = QLabel("TEAMS"); lab.setAlignment(Qt.AlignCenter)
        lab.setStyleSheet("color:#ddd; font: 11pt 'Arial';")

        self.scanned_list = QListWidget()
        self.scanned_list.setFixedWidth(120)
        self.scanned_list.setStyleSheet(
            "QListWidget{background:#111;color:#eee;border:1px solid #333; font: 16pt 'Arial';}"
        )

        teams_col.addWidget(lab)
        teams_col.addWidget(self.scanned_list, 1)

        lab_trial = QLabel("TRIAL"); lab_trial.setAlignment(Qt.AlignCenter)
        lab_trial.setStyleSheet("color:#ddd; font: 10pt 'Arial';")
        self.trial_count_lbl = QLabel("000")
        self.trial_count_lbl.setAlignment(Qt.AlignCenter)
        self.trial_count_lbl.setStyleSheet("color:#fff; font: 22pt 'Arial'; background:#222; border:1px solid #333; padding:6px;")

        teams_col.addWidget(lab_trial)
        teams_col.addWidget(self.trial_count_lbl)

        teams_wrap = QWidget(); teams_wrap.setLayout(teams_col)
        main_row.addWidget(teams_wrap, 0)  # aggiungi a destra

        # segnali
        self.team_big.editingFinished.connect(self._team_changed)
        self.team_big.editingFinished.connect(self._on_team_entered)
        self.ok_team_btn.clicked.connect(self._on_ok_team_clicked)
        self.finish_trial_btn.clicked.connect(self._on_finish_clicked)
        self.scanned_list.itemClicked.connect(self._on_scanned_team_clicked)
        self._trial_blink_on = False
        self._trial_blink_timer = QTimer(self)
        self._trial_blink_timer.setInterval(450)
        self._trial_blink_timer.timeout.connect(self._blink_trial_counter)

        self._update_trial_counter()

        # stato iniziale: tutto bloccato finché non c'è un team valido
        for r in self.rows:
            for f,p in r["buttons"]:
                f.setEnabled(False); p.setEnabled(False)
            r["kd"].setEnabled(False)
            r["rv"].setEnabled(False)

        for sp in (self.p1, self.p2, self.p3):
            sp.valueChanged.connect(self._setup_changed)

        self._fit_obst_name_width()
        self._sync_header_offset()

        # QTimer.singleShot(0, self._realign_header)

    def _load_setup(self):
        s = QSettings("RB_Ranking", "App")
        self.p1.setValue(int(s.value("trial/p1", self.p1.value())))
        self.p2.setValue(int(s.value("trial/p2", self.p2.value())))
        self.p3.setValue(int(s.value("trial/p3", self.p3.value())))
        self.kd_penalty.setValue(int(s.value("trial/kd", self.kd_penalty.value())))
        self.rv_penalty.setValue(int(s.value("trial/rv", self.rv_penalty.value())))

    def _save_setup(self, *_):
        s = QSettings("RB_Ranking", "App")
        s.setValue("trial/p1", self.p1.value())
        s.setValue("trial/p2", self.p2.value())
        s.setValue("trial/p3", self.p3.value())
        s.setValue("trial/kd", self.kd_penalty.value())
        s.setValue("trial/rv", self.rv_penalty.value())

    def _realign_header(self):
        try:
            if not self.rows or not self.hdr_wrap:
                return
            btn_fail1 = self.rows[0]["buttons"][0][0]
            x = btn_fail1.mapTo(self.hdr_wrap, QPoint(0, 0)).x()
            self._hdr_left_spacer.setFixedWidth(x)
        except Exception:
            pass
            
    def _sync_header_offset(self):
        INDEX_W = 32
        GAP = 2
        name_w = self.rows[0]["name"].width() if self.rows else 160
        self._hdr_left_spacer.setFixedWidth(INDEX_W + name_w + GAP)

    def _fit_obst_name_width(self):
        if not self.rows:
            return
        # 9 caratteri larghi + padding orizzontale (16+16 dallo stylesheet)
        fm = QFontMetrics(self.rows[0]["name"].font())
        need = fm.horizontalAdvance("WWWWWWWWW") + 32
        for r in self.rows:
            r["name"].setFixedWidth(need)
            try:
                self.colhdr_name.setFixedWidth(need)
            except Exception:
                pass

    def resizeEvent(self, e):
        super().resizeEvent(e)
        self._sync_header_offset()

    def showEvent(self, e):
        super().showEvent(e)
        # self._fit_obst_name_width()
        # self._sync_header_offset()

    # --- SCANNED TEAMS: helpers + handler TEAM ---
    def set_scanned_teams(self, nums):
        self.scanned_list.clear()
        for n in nums:
            it = QListWidgetItem(str(n))
            it.setTextAlignment(Qt.AlignCenter)   # ← centrato (usa Qt.AlignRight se li vuoi a destra)
            self.scanned_list.addItem(it)
            self._update_trial_counter()

    def _set_team_duplicate(self, is_dup: bool):
        # se False, svuota lo stylesheet e torna allo stile “normale” dell’app
        self.team_big.setStyleSheet(self._team_dup_css if is_dup else "")

    def _trial_count_style(self, blink_on: bool) -> str:
        if blink_on:
            return "color:#000; font: 22pt 'Arial'; background:#ffff66; border:1px solid #333; padding:6px;"
        return "color:#fff; font: 22pt 'Arial'; background:#222; border:1px solid #333; padding:6px;"

    def _is_trial_in_progress(self) -> bool:
        return not self.scanned_list.isEnabled()

    def _blink_trial_counter(self):
        if not self._is_trial_in_progress():
            self._trial_blink_timer.stop()
            self.trial_count_lbl.setStyleSheet(self._trial_count_style(False))
            return
        self._trial_blink_on = not self._trial_blink_on
        self.trial_count_lbl.setStyleSheet(self._trial_count_style(self._trial_blink_on))

    def _update_trial_counter(self):
        cnt = self.scanned_list.count()
        self.trial_count_lbl.setText(f"{cnt:03d}")

        if self._is_trial_in_progress():
            if not self._trial_blink_timer.isActive():
                self._trial_blink_on = False
                self._trial_blink_timer.start(450)
        else:
            if self._trial_blink_timer.isActive():
                self._trial_blink_timer.stop()
            self.trial_count_lbl.setStyleSheet(self._trial_count_style(False))

    def reset_all(self):
        # reset completo pagina TRIAL (NEW deve ripartire da 0)
        self._loading = True
        self._active_row = None
        self._current_team = None
        try:
            self.team_big.setReadOnly(False)
            self.team_big.setFocusPolicy(Qt.StrongFocus)
        except Exception:
            pass

        # sblocca header Trial (se era stato lockato dopo il 1° tentativo)
        try:
            from PySide6.QtWidgets import QSpinBox, QPushButton
            self.trial_header_locked = False
            for w in self.findChildren(QSpinBox, "hdrSpin"):
                w.setEnabled(True)
            for b in self.findChildren(QPushButton, "pmBtn"):
                b.setEnabled(True)
        except Exception:
            pass

        # valori default header (non fondamentale, ma pulito)
        try:
            for sp, val in (
                (self.p1, 100), (self.p2, 60), (self.p3, 20),
                (self.kd_penalty, 1), (self.rv_penalty, 3)
            ):
                sp.blockSignals(True)
                sp.setValue(val)
                sp.blockSignals(False)
        except Exception:
            pass

        # TEAM box + lista destra
        try:
            self.team_big.blockSignals(True)
            self.team_big.clear()
            self.team_big.blockSignals(False)
        except Exception:
            pass

        try:
            self.ok_team_btn.setEnabled(False)
        except Exception:
            pass

        try:
            self.scanned_list.clear()
            self._update_trial_counter()
        except Exception:
            pass

        # righe ostacoli: tutto spento e a zero
        for idx, r in enumerate(self.rows):
            try:
                r["name"].setText(f"{idx+1} Obst.")
            except Exception:
                pass

            try:
                r["pts_lbl"].setText("0")
            except Exception:
                pass

            r["locked"] = False
            r["next"] = 1
            r["base"] = 0

            for f, p in r["buttons"]:
                f.blockSignals(True); p.blockSignals(True)
                f.setChecked(False); p.setChecked(False)
                f.setEnabled(False); p.setEnabled(False)
                f.blockSignals(False); p.blockSignals(False)

            for key in ("kd", "rv"):
                try:
                    sp = r[key]
                    sp.blockSignals(True)
                    sp.setValue(0)
                    sp.setEnabled(False)
                    sp.blockSignals(False)
                except Exception:
                    pass

            try:
                self._reset_row_visual(r)
            except Exception:
                pass

        try:
            self.total_label.setText("Totale trial: 0")
            self.total_big.setText("points")
        except Exception:
            pass

        self._loading = False

    def _on_scanned_team_clicked(self, item: QListWidgetItem):
        t = (item.text() or "").strip()
        if not t:
            return
        self.team_big.setText(t)
        # ricarica dati TEAM dal DB + aggiorna UI
        self._team_changed()
        self._on_team_entered()
            
    def mark_completed(self, team_ids):
        for t in team_ids:
            it = self._ensure_item(str(t))
            self._mark_item(it, "green")
            self._resort_list_numeric()

    def _find_item(self, team: str):
        for i in range(self.scanned_list.count()):
            it = self.scanned_list.item(i)
            if it.text() == team:
                return it
        return None
    
    def _ensure_item(self, team: str):
        it = self._find_item(team)
        if it: return it
        it = QListWidgetItem(str(team))
        it.setTextAlignment(Qt.AlignCenter)
        self.scanned_list.addItem(it)
        self._update_trial_counter()
        return it

    def _mark_item(self, it, color: str):
        if not it:
            return
        if color == "yellow":
            it.setBackground(QColor("#ffff66")); it.setForeground(QColor("#000000"))
        elif color == "green":
            it.setBackground(QColor("#33cc66")); it.setForeground(QColor("#000000"))
        else:
            it.setBackground(QColor(0,0,0,0));   it.setForeground(QColor("#ffffff"))

    def _on_team_entered(self):
        team = self.team_big.text().strip()
        if not team:
            self._set_team_duplicate(False)
            self._update_trial_counter()
            return

        # se già completato → consultazione: lista ATTIVA
        if callable(self.is_team_completed) and self.is_team_completed(team):
            self._set_team_duplicate(True)
            self.scanned_list.setEnabled(True)  # <<< NEW
            it = self._ensure_item(team)
            self.scanned_list.setCurrentItem(it)
            self._mark_item(it, "green")
            self._current_team = team

            self._lock_header_once()
            self._update_ok_team_enabled()
            self.finish_trial_btn.setEnabled(False)
            self._update_trial_counter()
            return

        # TEAM in corso → lista DISATTIVA (evita consultazioni/errore)
        self._set_team_duplicate(False)
        self.scanned_list.setEnabled(False)  # <<< NEW

        # (vecchia logica, senza avvisi)
        if self._current_team and self._current_team != team:
            old_it = self._ensure_item(self._current_team)
            self._mark_item(old_it, "green")

        it = self._ensure_item(team)
        self.scanned_list.setCurrentItem(it)
        self._mark_item(it, "yellow")

        self._current_team = team
        self._lock_header_once()
        self.lock_trial_inputs_viewonly()   # view-only: ok_team già OFF
        # NON toccare FINISH qui (deve restare com’è)
        return

    def lock_trial_inputs_viewonly(self):
        # blocca tutta la griglia (vista sola lettura)
        for r in self.rows:
            for f, p in r["buttons"]:
                f.setEnabled(False)
                p.setEnabled(False)
            try:
                r["kd"].setEnabled(False)
                r["rv"].setEnabled(False)
            except Exception:
                pass
        self.ok_team_btn.setEnabled(False)

    def _is_all_rows_concluded(self):
        # una riga è conclusa se r["done"] è True, oppure se ha fatto 3 FAIL (next>3)
        for r in self.rows[:self.n_obst]:
            if not r.get("done") and r.get("next", 1) <= 3:
                return False
        return True
    
    def _update_ok_team_enabled(self):
        on = bool(self._current_team) and self._is_all_rows_concluded()
        self.ok_team_btn.setEnabled(on)
    
    def _lock_header_once(self):
        if self._header_locked:
            return
        for sp in (self.p1, self.p2, self.p3, self.kd_penalty, self.rv_penalty):
            sp.setEnabled(False)
        self._header_locked = True

    def _on_finish_clicked(self):
        # colora tutti gli scansionati di verde e resetta il “corrente”
        for i in range(self.scanned_list.count()):
            self._mark_item(self.scanned_list.item(i), "green")
        self._current_team = None
        self.scanned_list.setEnabled(True)   # <<< NEW
        self._update_trial_counter()
    # --- FINE _on_finish_clicked ---

    def _on_ok_team_clicked(self):
        t = (self.team_big.text() or "").strip()
        if not t:
            return
        if not self._is_all_rows_concluded():
            return  # serve che le N righe siano concluse

        # 1) marca COMPLETED nel DB (crea il team se serve) e salva
        try:
            mw = self.window()
            if getattr(mw, "db", None):
                key = t.lstrip("0") or "0"
                tdict = mw.db["teams"].setdefault(key, {})
                tri = tdict.setdefault("trial", {})
                tri.setdefault("states", [0]*9)
                tri.setdefault("points", [0]*9)
                tri.setdefault("kd", [0]*9)
                tri.setdefault("rv", [0]*9)

                # salva KD/RV (e riallinea i punti finali come da UI)
                for i in range(min(self.n_obst, len(self.rows))):
                    tri["kd"][i] = int(self.rows[i]["kd"].value())
                    tri["rv"][i] = int(self.rows[i]["rv"].value())
                    tri["points"][i] = int(self.rows[i]["pts_lbl"].text() or "0")

                tri["completed"] = True
                save_db_atomic(mw.db, mw.db_path)
        except Exception:
            pass

        # 2) colora in verde l’item in lista (lo crea se manca)
        it = self._ensure_item(t)
        self._mark_item(it, "green")

        # 3) reset UI per il prossimo equipaggio
        self._current_team = None
        self.scanned_list.clearSelection()
        self.team_big.clear()
        for r in self.rows:
            self._reset_row_visual(r)
            for f, p in r["buttons"]:
                f.setEnabled(False); p.setEnabled(False)
            r["kd"].setValue(0); r["kd"].setEnabled(False)
            r["rv"].setValue(0); r["rv"].setEnabled(False)
            r["locked"] = True; r["next"] = 1

        self.total_label.setText("Totale trial: 0")
        self.total_big.setText("points")
        self._update_ok_team_enabled()
        self.finish_trial_btn.setEnabled(True)
        self.scanned_list.setEnabled(True)   # <<< NEW
        self._update_trial_counter()

    def _resort_list_numeric(self):
        items = []
        for i in range(self.scanned_list.count()):
            items.append(self.scanned_list.item(i).text())
        items.sort(key=lambda s: int(s) if s.isdigit() else s)

        self.scanned_list.clear()
        for t in items:
            it = QListWidgetItem(str(t))
            it.setTextAlignment(Qt.AlignCenter)
            self.scanned_list.addItem(it)
            # ricolora in base allo stato
            if callable(self.is_team_completed) and self.is_team_completed(t):
                self._mark_item(it, "green")
            elif self._current_team and t == self._current_team:
                self._mark_item(it, "yellow")

    # API chiamate da MainWindow
    def load_setup(self, attempt_points, obstacle_names):
        self._loading = True
        self.p1.setValue(int(attempt_points[0]))
        self.p2.setValue(int(attempt_points[1]))
        self.p3.setValue(int(attempt_points[2]))
        for row, nm in zip(self.rows, obstacle_names):
            row["name"].setText(nm[:12])
        self._loading = False

    def read_setup(self):
        attempt_points = [self.p1.value(), self.p2.value(), self.p3.value()]
        obstacle_names = [r["name"].text() for r in self.rows]  # << FIX
        return attempt_points, obstacle_names

    def load_team_points(self, points_list):
        total = 0
        for row, v in zip(self.rows, points_list):
            row["pts_lbl"].setText(str(int(v)))
            total += int(v)
        self.total_label.setText(f"Totale trial: {total}")
        self.total_big.setText(str(total))

    def _lock_trial_header(self):
        """Blocca una volta sola i controlli dell'header Trial."""
        if getattr(self, "trial_header_locked", False):
            return
        from PySide6.QtWidgets import QSpinBox, QPushButton
        # Disabilita tutti gli spin dell'header (identificati da objectName 'hdrSpin')
        for w in self.findChildren(QSpinBox, "hdrSpin"):
            w.setEnabled(False)
        # Disabilita i pulsanti +/- dell'header (objectName 'pmBtn')
        for b in self.findChildren(QPushButton, "pmBtn"):
            b.setEnabled(False)
        self.trial_header_locked = True

    def _maybe_lock_trial_header(self):
        """Lock idempotente (chiama quando parte il primo tentativo)."""
        if not getattr(self, "trial_header_locked", False):
            self._lock_trial_header()

    def _on_row_clicked(self, row_idx:int):
        """
        Sblocca SOLO la prossima riga utile, in ordine:
        - cliccabile solo la riga immediatamente successiva all’ultima conclusa
        - 'conclusa' = chosen!=None (pass) oppure next>3 (tre fail)
        """
        if row_idx >= getattr(self, "n_obst", 9):
            if not getattr(self, "_current_team", None):
                QMessageBox.warning(self, "TEAM", "Inserisci prima il TEAM.")
                self.team_big.setFocus()
                self.team_big.selectAll()
                return
            return
        # trova l'ultima riga conclusa
        last_done = -1
        for j, rj in enumerate(self.rows):
            if rj.get("done"):
                last_done = j
            else:
                break
        target = last_done + 1
        if row_idx != target:
            return

        # attiva questa riga e blocca la precedente
        self._activate_row(row_idx)

        # sblocca SOLO il 1º tentativo della riga target; penalità attive
        r = self.rows[row_idx]
        r["locked"] = False
        r["next"] = 1
        for j,(f,p) in enumerate(r["buttons"], start=1):
            en = (j == 1)
            f.setEnabled(en); p.setEnabled(en)
        r["kd"].setEnabled(True)
        r["rv"].setEnabled(True)

    # handler interni
    def _team_changed(self):
        t = self.team_edit.text().strip()
        if not t:
            return
        if callable(self.on_team_change):
            self.on_team_change(t)

    def _setup_changed(self, *_):
        if self._loading:
            return
        if self.on_team_change:
            # salva solo il setup (punti tentativi + nomi ostacoli)
            self.on_team_change(self.team_big.text().strip(), only_setup=True)

    def _activate_row(self, row_idx:int):
        # non bloccare definitivamente la riga precedente: serve poter correggere risultati
        self._active_row = row_idx

    def _toggle_attempt(self, row_idx:int, attempt:int, role:str, *_):
        """Toggle rosso/verde per il tentativo 'attempt' nella riga 'row_idx'."""
        self._maybe_lock_trial_header()
        if row_idx >= getattr(self, "n_obst", 9):
            return
        if not getattr(self, "_current_team", None):
            # annulla il click (il bottone è già stato togglato da Qt)
            try:
                f0, p0 = self.rows[row_idx]["buttons"][attempt-1]
                f0.blockSignals(True); p0.blockSignals(True)
                f0.setChecked(False); p0.setChecked(False)
                f0.blockSignals(False); p0.blockSignals(False)
            except Exception:
                pass
            QMessageBox.warning(self, "TEAM", "Inserisci prima il TEAM.")
            self.team_big.setFocus()
            self.team_big.selectAll()
            return
        self._activate_row(row_idx)
        r = self.rows[row_idx]

        # se la riga è locked, prova lo sblocco sequenziale (click bottone = click nome)
        if r.get("locked", True):
            self._on_row_clicked(row_idx)
            if r.get("locked", True):
                return

        nxt    = r.get("next", 1)
        chosen = r.get("chosen")

        # consenti: il prossimo, quello già scelto, oppure QUALSIASI precedente se non c'è ancora un 'pass'
        if not (attempt == nxt or (chosen == attempt) or (chosen is None and attempt < nxt)):
            return

        f, p = r["buttons"][attempt-1]

        if role == "pass":
            if p.isChecked():
                # PASS scelto → rosso off, riga conclusa
                f.setChecked(False)
                r["chosen"] = attempt
                r["done"]   = True
                base_map = [self.p1.value(), self.p2.value(), self.p3.value()]
                r["base"]  = int(base_map[attempt-1])
                for j,(fj,pj) in enumerate(r["buttons"], start=1):
                    en = (j == attempt)           # solo questo resta attivo
                    fj.setEnabled(en); pj.setEnabled(en)
                r["next"] = attempt + 1
                self._recompute_row_points(row_idx)

                if callable(self.on_row_update):
                    self.on_row_update(row_idx, attempt, int(r["pts_lbl"].text() or "0"))

                # vai alla riga successiva (se esiste)
                if (row_idx + 1) < getattr(self, "n_obst", 9):
                    self._on_row_clicked(row_idx + 1)

            else:
                # tolto il PASS → torna neutro e riapri i tentativi fino a nxt
                r["chosen"] = None
                r["done"]   = False
                r["base"]   = 0
                self._set_attempt_ready(r, attempt)
                for j,(fj,pj) in enumerate(r["buttons"], start=1):
                    en = (j <= nxt)
                    fj.setEnabled(en); pj.setEnabled(en)
                self._recompute_row_points(row_idx)

        else:  # role == "fail"
            if f.isChecked():
                # FAIL scelto
                p.setChecked(False)
                r["base"] = 0
                if attempt == 3:
                    # terzo FAIL → riga conclusa
                    r["done"] = True
                    r["next"] = 4
                    r["locked"] = False
                    self._recompute_row_points(row_idx)
                    if callable(self.on_row_update):
                        self.on_row_update(row_idx, 4, 0)   # <-- 4 = "3 fail"
                    # vai alla riga successiva (se esiste)
                    if (row_idx + 1) < getattr(self, "n_obst", 9):
                        self._on_row_clicked(row_idx + 1)
                else:
                    r["next"] = max(nxt, attempt+1)
                    self._recompute_row_points(row_idx)

                # abilita i tentativi fino al nuovo 'next'
                for j,(fj,pj) in enumerate(r["buttons"], start=1):
                    en = (j <= r["next"])
                    fj.setEnabled(en); pj.setEnabled(en)

            else:
                # tolto il FAIL → neutro; lascia attivi i tentativi fino a 'next'
                self._set_attempt_ready(r, attempt)
                for j,(fj,pj) in enumerate(r["buttons"], start=1):
                    en = (j <= r.get("next",1))
                    fj.setEnabled(en); pj.setEnabled(en)
                self._recompute_row_points(row_idx)

        # aggiorna resa visiva dei tre tentativi
        for j in (1, 2, 3):
            self._update_attempt_visual(r, j)

    def _update_attempt_visual(self, r, attempt: int):
        f, p = r["buttons"][attempt - 1]
        selected = f.isChecked() or p.isChecked()

        if selected:
            if f.isChecked():
                f.setVisible(True);  f.setFixedWidth(112)
                p.setVisible(False); p.setFixedWidth(0)
            else:  # pass
                p.setVisible(True);  p.setFixedWidth(112)
                f.setVisible(False); f.setFixedWidth(0)
        else:
            # nessuna scelta -> coppia visibile (permette cambiare)
            f.setVisible(True);  p.setVisible(True)
            f.setFixedWidth(56); p.setFixedWidth(56)

        for b in (f, p):
            b.style().unpolish(b); b.style().polish(b); b.update()

    def _set_attempt_ready(self, r, attempt:int):
        """Torna allo stato 'coppia visibile' per il tentativo indicato."""
        f, p = r["buttons"][attempt-1]
        f.setEnabled(True); p.setEnabled(True)
        f.setChecked(False); p.setChecked(False)
        self._update_attempt_visual(r, attempt)

    def _reset_row_visual(self, r):
        # neutro: entrambi i bottoni visibili, stretti, non selezionati
        for f, p in r["buttons"]:
            f.setChecked(False); p.setChecked(False)
            f.setVisible(True);  p.setVisible(True)
            f.setFixedWidth(56); p.setFixedWidth(56)
            for btn in (f, p):
                btn.style().unpolish(btn); btn.style().polish(btn); btn.update()

        # l'abilitazione viene gestita da init_rows_from_team
        r["done"] = False
        r["base"] = 0
        r["chosen"] = None
        r["next"] = 1

    def init_rows_from_team(self, states, points, kd=None, rv=None):
        """Con un team valido: abilita SOLO la prima riga aperta; le precedenti bloccate, le successive bloccate."""
        self._active_row = None

        # aggiorna label punti e individua la prima riga ancora aperta
        first_open = None
        for idx, r in enumerate(self.rows):
            s  = int(states[idx]) if idx < len(states) else 0
            pt = int(points[idx]) if idx < len(points) else 0
            k = int(kd[idx]) if kd and idx < len(kd) else 0
            v = int(rv[idx]) if rv and idx < len(rv) else 0

            r["kd"].blockSignals(True); r["kd"].setValue(k); r["kd"].blockSignals(False)
            r["rv"].blockSignals(True); r["rv"].setValue(v); r["rv"].blockSignals(False)
            r["pts_lbl"].setText(str(pt))
            self._reset_row_visual(r)
            # ricostruisci i bottoni dal state salvato:
            # 0 = vuoto, 1..3 = PASS al tentativo, 4 = 3 FAIL
            if s in (1, 2, 3, 4):
                # FAIL dei tentativi precedenti (o tutti e 3 se s==4)
                if s == 4:
                    fail_attempts = (1, 2, 3)
                else:
                    fail_attempts = tuple(range(1, s))  # es: s=3 -> fail 1,2

                for a in fail_attempts:
                    f, p = r["buttons"][a - 1]
                    f.blockSignals(True); p.blockSignals(True)
                    f.setChecked(True);  p.setChecked(False)
                    f.blockSignals(False); p.blockSignals(False)
                    self._update_attempt_visual(r, a)

                # PASS finale (solo se s=1..3)
                if s in (1, 2, 3):
                    a = s
                    f, p = r["buttons"][a - 1]
                    f.blockSignals(True); p.blockSignals(True)
                    f.setChecked(False); p.setChecked(True)
                    f.blockSignals(False); p.blockSignals(False)
                    self._update_attempt_visual(r, a)

                r["done"] = True
                r["next"] = 4
                r["locked"] = True

            if first_open is None and s == 0 and pt == 0:
                first_open = idx

        # se tutto chiuso -> nessuna abilitazione
        if first_open is None:
            for r in self.rows:
                r["locked"] = True
                r["next"] = 1
                for f,p in r["buttons"]:
                    f.setEnabled(False); p.setEnabled(False)
                r["kd"].setEnabled(False)   # le penalità restano attive col team valido
                r["rv"].setEnabled(False)
            # totale
            tot = sum(int(x["pts_lbl"].text() or "0") for x in self.rows)
            self.total_label.setText(f"Totale trial: {tot}")
            return

        # applica il blocco sequenziale
        for idx, r in enumerate(self.rows):
            if idx < first_open:
                # concluse → tutto bloccato
                r["locked"] = True; r["next"] = 1
                for f,p in r["buttons"]: f.setEnabled(False); p.setEnabled(False)
                r["kd"].setEnabled(False); r["rv"].setEnabled(False)
            elif idx == first_open:
                # aperta → solo 1º tentativo e penalità ON
                r["locked"] = False; r["next"] = 1
                for j,(f,p) in enumerate(r["buttons"], start=1):
                    en = (j == 1); f.setEnabled(en); p.setEnabled(en)
                r["kd"].setEnabled(True); r["rv"].setEnabled(True)
            else:
                # future → tutto OFF
                r["locked"] = True; r["next"] = 1
                for f,p in r["buttons"]: f.setEnabled(False); p.setEnabled(False)
                r["kd"].setEnabled(False); r["rv"].setEnabled(False)

        # disattiva/oscura tutte le righe oltre N
        for i, r in enumerate(self.rows):
            if i >= getattr(self, "n_obst", 9):
                r["name"].setEnabled(False)
                r["name"].setStyleSheet("color:#777;")
                for f, p in r["buttons"]:
                    f.setEnabled(False); p.setEnabled(False)
                    f.setChecked(False); p.setChecked(False)
                    f.setVisible(True);  p.setVisible(True)
                    f.setFixedWidth(56); p.setFixedWidth(56)
                r["kd"].setEnabled(False)
                r["rv"].setEnabled(False)
                r["pts_lbl"].setText("0")
                r["pts_lbl"].setStyleSheet("color:#777;")
            else:
                # primi N: stile normale
                r["name"].setEnabled(True)
                r["name"].setStyleSheet("")
                r["pts_lbl"].setStyleSheet("")

        # totale a schermo (solo primi N)
        tot = sum(int(x["pts_lbl"].text() or "0") for x in self.rows[:self.n_obst])
        self.total_label.setText(f"Totale trial: {tot}")

    def _row_final_points(self, r):
        base = int(r["base"])
        pen = r["kd"].value() * self.kd_penalty.value() + r["rv"].value() * self.rv_penalty.value()
        return base - pen

    def _recompute_row_points(self, row_idx: int):
        if row_idx < 0 or row_idx >= len(self.rows):
            return
        r = self.rows[row_idx]
        finalp = self._row_final_points(r)
        r["pts_lbl"].setText(str(finalp))

        # aggiorna totale una sola volta
        total = sum(int(x["pts_lbl"].text() or "0") for x in self.rows[:self.n_obst])
        self.total_label.setText(f"Totale trial: {total}")
        self.total_big.setText(str(total))
        self._update_ok_team_enabled()
        self.finish_trial_btn.setEnabled(True)

    def _recompute_all_rows(self):
        for i in range(len(self.rows)):
            self._recompute_row_points(i)

class RankingPage(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        titles = ["FinalScore", "Accuracy", "CAP", "Speed", "Regularity", "Trial"]
        row = QHBoxLayout(self)
        row.setContentsMargins(24,24,24,24)
        row.setSpacing(16)

        self.cols = {}
        self.hdrs = {}
        self.pagers = {}  # name -> {'timer': QTimer, 'page': 0, 'size': 10}

        for t in titles:
            col = QVBoxLayout(); col.setSpacing(8)

            hdr = QLabel(t); hdr.setObjectName("rkHdr"); hdr.setAlignment(Qt.AlignCenter)
            lst = QListWidget(); lst.setObjectName("rkList")
            self.hdrs[t] = hdr
            hdr.setProperty("active", False)
            hdr.setCursor(Qt.PointingHandCursor)
            hdr.mousePressEvent = (lambda e, name=t: self._toggle_pager(name))
            lst.setUniformItemSizes(True)
            lst.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
            lst.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)  # UI pulita, gestiamo noi lo scroll

            col.addWidget(hdr)
            col.addWidget(lst, 1)

            wrap = QWidget(); wrap.setLayout(col)
            stretch = 11 if t == "FinalScore" else 10
            row.addWidget(wrap, stretch)

            self.cols[t] = lst

    # comodo per dopo
    def set_column(self, name: str, rows):
        lst = self.cols.get(name) or self.cols.get(name.capitalize())
        if not lst:
            return
        lst.clear()

        for pos, (team, val) in enumerate(rows, start=1):
            roww = QWidget()
            lay = QHBoxLayout(roww)
            lay.setContentsMargins(16, 10, 16, 10)
            lay.setSpacing(20)

            # numerino posizione (tutte le colonne)
            idx_lbl = QLabel(str(pos))
            idx_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
            idx_lbl.setFixedWidth(44)
            idx_lbl.setStyleSheet("color:#e6e6e6; font: 20pt 'Arial Black';")  # opzionale
            lay.addWidget(idx_lbl, 0, Qt.AlignLeft)

            team_lbl = QLabel(str(team))
            team_lbl.setStyleSheet("color:#1db954; font: 24pt 'Arial';")

            disp_val = f"{val:.1f}".replace(".", ",") if name == "FinalScore" else str(val)
            val_lbl  = QLabel(disp_val)
            val_lbl.setStyleSheet("color:#ff3b30; font: 24pt 'Arial';")

            lay.addWidget(team_lbl, 0, Qt.AlignLeft)
            lay.addStretch(1)
            lay.addWidget(val_lbl, 0, Qt.AlignRight)

            # altezza riga comoda
            rowh = max(team_lbl.fontMetrics().height(), val_lbl.fontMetrics().height()) + 14
            roww.setMinimumHeight(rowh)

            it = QListWidgetItem()
            it.setSizeHint(roww.minimumSizeHint())
            lst.addItem(it)
            lst.setItemWidget(it, roww)

        # reset paging
        pager = self.pagers.get(name)
        if pager:
            pager['page'] = 0
            if pager['timer'].isActive() and lst.count():
                lst.scrollToItem(lst.item(0), QAbstractItemView.PositionAtTop)

    def clear_all(self):
        for lst in self.cols.values():
            lst.clear()

    def _toggle_pager(self, name: str):
        """Click sull'intestazione: ON/OFF dello scroll automatico a pagine (10 righe, 18s)."""
        lst = self.cols.get(name)
        hdr = self.hdrs.get(name)
        if not lst or not hdr:
            return
        pager = self.pagers.get(name)
        if not pager:
            pager = {'timer': QTimer(self), 'page': 0, 'size': 10}
            pager['timer'].setInterval(18000)  # 18 secondi
            pager['timer'].timeout.connect(lambda n=name: self._pager_step(n))
            self.pagers[name] = pager

        if pager['timer'].isActive():
            pager['timer'].stop()
            hdr.setProperty("active", False)
            hdr.style().unpolish(hdr); hdr.style().polish(hdr); hdr.update()
        else:
            # parte dal blocco attuale (tipicamente i primi 10), poi dopo 18s va ai successivi
            pager['page'] = 0
            pager['timer'].start()
            hdr.setProperty("active", True)
            hdr.style().unpolish(hdr); hdr.style().polish(hdr); hdr.update()

    def _pager_step(self, name: str):
        lst = self.cols.get(name)
        pager = self.pagers.get(name)
        if not lst or not pager:
            return
        count = lst.count()
        ps = pager['size']
        if count <= ps:
            return
        pages = (count + ps - 1) // ps
        pager['page'] = (pager['page'] + 1) % pages
        start = pager['page'] * ps
        if start >= count:
            start = max(0, count - 1)
        it = lst.item(start)
        if it:
            lst.scrollToItem(it, QAbstractItemView.PositionAtTop)

class MainWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("RB Ranking")
        self.settings = QSettings("RB_Ranking", "App")

        # forza titlebar con menu di sistema, Chiudi e Minimizza (niente Massimizza)
        #self.setWindowFlags(self.windowFlags() | Qt.Window | Qt.WindowTitleHint | Qt.WindowSystemMenuHint | Qt.WindowMinimizeButtonHint | Qt.WindowCloseButtonHint)
        #self.setWindowFlag(Qt.WindowMaximizeButtonHint, False)

        self.setObjectName("root")

        # MENU: Istruzioni (IT/ES/EN) - uno o nessuno
        self._instructions_lang = None
        self._instr_last_tab = None
        self._instr_popup_enabled = (str(self.settings.value("instr_popup_enabled", "1")) == "1")

        self.menu_bar = QMenuBar()
        # MENU: File / Print
        menu_file = self.menu_bar.addMenu("File")

        act_new = QAction("New", self)
        act_new.setShortcut(QKeySequence("Ctrl+N"))
        act_new.triggered.connect(self.reset_new_event)
        menu_file.addAction(act_new)


        menu_file.addSeparator()

        act_open = QAction("Open…", self)
        act_open.setShortcut(QKeySequence("Ctrl+O"))
        act_open.triggered.connect(self.open_db_dialog)
        menu_file.addAction(act_open)

        menu_print = self.menu_bar.addMenu("Print")
        act_print = QAction("Print (CTRL+P)", self)
        act_print.triggered.connect(self.print_pdf_dialog)
        menu_print.addAction(act_print)

        m = self.menu_bar.addMenu("Languages")

        self.act_instr_it = QAction("Italiano", self)
        self.act_instr_it.setCheckable(True)
        self.act_instr_es = QAction("Español", self)
        self.act_instr_es.setCheckable(True)
        self.act_instr_en = QAction("English", self)
        self.act_instr_en.setCheckable(True)

        m.addAction(self.act_instr_it)
        m.addAction(self.act_instr_es)
        m.addAction(self.act_instr_en)

        menu_instr = self.menu_bar.addMenu("Instructions")

        self.act_popup_show = QAction("Show popup (F1)", self)
        # self.act_popup_show.setShortcut(QKeySequence("F1"))
        # self.act_popup_show.setShortcutContext(Qt.ApplicationShortcut)

        self.act_popup_hide = QAction("Hide popup (Ctrl+F1)", self)
        # self.act_popup_hide.setShortcut(QKeySequence("Ctrl+F1"))
        # self.act_popup_hide.setShortcutContext(Qt.ApplicationShortcut)

        self.act_popup_show.triggered.connect(self._instr_popup_show)
        self.act_popup_hide.triggered.connect(self._instr_popup_hide)

        menu_instr.addAction(self.act_popup_show)
        menu_instr.addAction(self.act_popup_hide)

        # --- Help: Check for update ---
        menu_help = self.menu_bar.addMenu("Update")
        self.act_updates = QAction("Check for updates", self)
        self.act_updates.triggered.connect(self.check_for_update)
        menu_help.addAction(self.act_updates)

        # scorciatoie globali (funzionano anche se il popup ha il focus)
        self._sc_f1 = QShortcut(QKeySequence("F1"), self)
        self._sc_f1.setContext(Qt.ApplicationShortcut)
        self._sc_f1.activated.connect(self._instr_popup_show)

        self._sc_ctrl_f1 = QShortcut(QKeySequence("Ctrl+F1"), self)
        self._sc_ctrl_f1.setContext(Qt.ApplicationShortcut)
        self._sc_ctrl_f1.activated.connect(self._instr_popup_hide)

        self.act_instr_it.toggled.connect(partial(self._on_instr_lang, "it"))
        self.act_instr_es.toggled.connect(partial(self._on_instr_lang, "es"))
        self.act_instr_en.toggled.connect(partial(self._on_instr_lang, "en"))

        saved = str(self.settings.value("instr_lang", "") or "")
        if saved in ("it", "es", "en"):
            mp = {"it": self.act_instr_it, "es": self.act_instr_es, "en": self.act_instr_en}
            a = mp[saved]
            a.blockSignals(True)
            a.setChecked(True)
            a.blockSignals(False)
            self._instructions_lang = saved

        # HEADER
        self.header = Header()

        # TABS
        self.tabs = QTabWidget()
        self.tabs.setTabPosition(QTabWidget.North)
        self.tabs.tabBar().setExpanding(True)

        # --- CRITERIA ---
        self.page_criteria = QWidget()
        crit = QVBoxLayout(self.page_criteria)
        crit.setContentsMargins(24, 24, 24, 24)
        crit.setSpacing(24)

        row = QHBoxLayout()
        row.setSpacing(36)

        self.card_accuracy   = ToggleCard("Accuracy",    "mult")
        self.card_cap        = ToggleCard("CAP",         "mult")
        self.card_speed      = ToggleCard("Speed Limits","mult")
        self.card_regularity = ToggleCard("Regularity",  "regularity")
        self.card_trial      = ToggleCard("TRIAL",       "mult")

        # aggiungi le prime 4 card nella riga
        for c in (self.card_accuracy, self.card_cap, self.card_speed, self.card_regularity):
            c.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
            c.button.setMinimumHeight(150)
            c.button.setMinimumWidth(220)
            row.addWidget(c)

        # card TRIAL (solo bottone come le altre)
        self.card_trial.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.card_trial.button.setMinimumHeight(150)
        self.card_trial.button.setMinimumWidth(220)
        row.addWidget(self.card_trial)

        # N ostacoli (interno, non visibile: lo usiamo per salvare su DB)
        self.trial_n_spin = QSpinBox()
        self.trial_n_spin.setRange(1, 9)
        self.trial_n_spin.setValue(1)
        self.trial_n_spin.setVisible(False)

        crit.addLayout(row)
        crit.addSpacing(12)

        # --- TRIAL (setup dentro CRITERIA) ---
        self.lbl_trial_title = QLabel("TRIAL")
        self.lbl_trial_title.setAlignment(Qt.AlignCenter)
        self.lbl_trial_title.setStyleSheet('color:#999; font: bold 26pt "Arial";')
        crit.addWidget(self.lbl_trial_title, 0, Qt.AlignHCenter)

        names_grid = QGridLayout()
        names_grid.setHorizontalSpacing(12)
        names_grid.setVerticalSpacing(10)

        self.trial_name_edits = []
        for i in range(9):
            e = QLineEdit()
            e.setMaxLength(12)
            e.setAlignment(Qt.AlignCenter)
            e.setObjectName("obstName")
            e.setPlaceholderText("Obst.name")
            e.setFixedWidth(170)
            e.setFixedHeight(36)
            e.setReadOnly(True)

            orig_press = e.mousePressEvent
            def _press(ev, idx=i, w=e, orig=orig_press):
                # tasto destro: menu DELETE (solo sull'ultimo verde)
                if ev.button() == Qt.RightButton:
                    self._criteria_obst_context_menu(idx, w.mapToGlobal(ev.position().toPoint()))

                    ev.accept()
                    return

                self._criteria_obst_clicked(idx, ev)  # <-- passa ev
                ev.accept()                           # <-- blocca il comportamento “click normale”
            e.mousePressEvent = _press

            e.editingFinished.connect(self._criteria_push_trial_setup)
            self.trial_name_edits.append(e)
            names_grid.addWidget(e, i // 3, i % 3)

        names_wrap = QWidget()
        names_wrap.setLayout(names_grid)
        crit.addWidget(names_wrap, 0, Qt.AlignHCenter)

        self.lbl_pp_title = QLabel("Points & Penalty")
        self.lbl_pp_title.setAlignment(Qt.AlignCenter)
        self.lbl_pp_title.setStyleSheet('color:#999; font: bold 20pt "Arial";')
        crit.addWidget(self.lbl_pp_title, 0, Qt.AlignHCenter)

        pp = QHBoxLayout()
        pp.setSpacing(18)

        def _mk_pp(label, rng, default):
            col = QVBoxLayout(); col.setSpacing(4)
            lbl = QLabel(label); lbl.setObjectName("trialLabel"); lbl.setAlignment(Qt.AlignCenter)
            sp = QSpinBox(); sp.setRange(rng[0], rng[1]); sp.setValue(default)
            sp.setObjectName("hdrSpin")
            sp.setButtonSymbols(QAbstractSpinBox.NoButtons)
            sp.setAlignment(Qt.AlignCenter)
            sp.setFixedWidth(112)
            col.addWidget(lbl)
            col.addWidget(sp, alignment=Qt.AlignCenter)
            wrap = QWidget(); wrap.setLayout(col)
            pp.addWidget(wrap)
            return sp

        self.trial_p1 = _mk_pp("1ª attempt", (0, 999), 100)
        self.trial_p2 = _mk_pp("2ª attempt", (0, 999), 60)
        self.trial_p3 = _mk_pp("3ª attempt", (0, 999), 20)
        self.trial_kd = _mk_pp("Knocked Down", (0, 50), 1)
        self.trial_rv = _mk_pp("Reverse", (0, 50), 3)

        pp_wrap = QWidget()
        pp_wrap.setLayout(pp)
        crit.addWidget(pp_wrap, 0, Qt.AlignHCenter)

        # wiring TRIAL setup (da CRITERIA -> TRIAL)
        self.card_trial.button.toggled.connect(self._criteria_trial_toggled)
        for sp in (self.trial_p1, self.trial_p2, self.trial_p3, self.trial_kd, self.trial_rv):
            sp.valueChanged.connect(self._criteria_push_trial_setup)

        self.sim_btn = QPushButton("SIM")
        self.sim_btn.setObjectName("simBtn")
        self.sim_btn.setCheckable(True)
        self.sim_btn.setMinimumSize(220, 80)

        self.ok_btn = QPushButton("FINISH")
        self.ok_btn.setObjectName("okBtn")
        self.ok_btn.setMinimumSize(220, 80)

        btn_row = QHBoxLayout()
        btn_row.addWidget(self.sim_btn)
        btn_row.addStretch(1)
        btn_row.addWidget(self.ok_btn)
        btn_wrap = QWidget()
        btn_wrap.setLayout(btn_row)
        crit.addWidget(btn_wrap)

        # stato iniziale della sezione TRIAL
        self._criteria_trial_toggled(self.card_trial.button.isChecked())

        # --- SCAN ---
        self.page_scan = ScanPane(self.on_scan)
        self.page_scan.finish_btn.setEnabled(False)

        # segnaposto per gli altri tab
        self.page_trial = TrialPage(self.on_trial_team_change, self.on_trial_row_update)
        self.page_trial.is_team_completed = self._is_team_completed
        self._criteria_push_trial_setup()

        self.page_ranking = RankingPage()

        self.tabs.addTab(self.page_criteria, "CRITERIA")
        self.tabs.addTab(self.page_scan,     "SCAN")
        self.tabs.addTab(self.page_trial,    "TRIAL")
        self.tabs.addTab(self.page_ranking,  "RANKING")

        QShortcut(QKeySequence("Ctrl+P"), self, activated=self.print_pdf_dialog)

        # stato iniziale: solo CRITERIA abilitato
        self.tabs.setTabEnabled(1, True)
        self.tabs.setTabEnabled(2, True)
        self.tabs.setTabEnabled(3, True)

        # wiring
        self.ok_btn.clicked.connect(self.lock_criteria_and_open_scan)
        self.sim_btn.toggled.connect(self._on_sim_toggled)

        # refresh SIM al cambio criteri
        for card in (self.card_accuracy, self.card_cap, self.card_speed, self.card_regularity, self.card_trial):
            card.button.toggled.connect(self._on_criteria_changed)

            # QDoubleSpinBox / simili (es. Regularity km/h)
            if hasattr(card, "control") and hasattr(card.control, "valueChanged"):
                card.control.valueChanged.connect(self._on_criteria_changed)

            # MultiplierControl (Accuracy/CAP/Speed/Trial): lo spin sta dentro
            if hasattr(card, "control") and hasattr(card.control, "spin") and hasattr(card.control.spin, "valueChanged"):
                card.control.spin.valueChanged.connect(self._on_criteria_changed)

        self.page_scan.finish_btn.clicked.connect(self.enable_trial_and_ranking)
        self.header.open_btn.clicked.connect(self.open_db_dialog)
        self.page_trial.finish_trial_btn.clicked.connect(self.on_trial_finish)

        # LAYOUT ROOT
        root = QVBoxLayout(self)
        root.setContentsMargins(0, 0, 0, 0)
        root.setSpacing(0)
        root.addWidget(self.menu_bar)
        root.addWidget(self.header)
        root.addWidget(self._hline())
        root.addWidget(self.tabs)
        #self.setWindowFlag(Qt.WindowMinimizeButtonHint, True)  # (facoltativo) tieni il minimizza

        self.apply_styles()
        self.db = None
        self.db_path = None
        self.db_dir = None
        self.scan_count = 0
        self.tabs.currentChanged.connect(self._on_tab_changed)
        # mostra popup istruzioni all'avvio (se lingua memorizzata)
        if self._instructions_lang:
            QTimer.singleShot(0, lambda: self._maybe_show_instructions(self.tabs.currentIndex(), force=True))

        self.scan_started = False
        self.trial_finished = False

        self.sim_mode = False
        self._sim_backup = None
        self._sim_base = None

        self._enforce_flow_locks()

    def _instr_popup_show(self):
        self._instr_popup_enabled = True
        self.settings.setValue("instr_popup_enabled", "1")
        self._maybe_show_instructions(self.tabs.currentIndex(), force=True)

    def _instr_popup_hide(self):
        self._instr_popup_enabled = False
        self.settings.setValue("instr_popup_enabled", "0")
        self._instr_last_tab = None
        if getattr(self, "_instr_box", None) is not None:
            self._instr_box.hide()

    def closeEvent(self, event):
        try:
            self._instr_popup_hide()
            if getattr(self, "_instr_box", None) is not None:
                self._instr_box.close()
        except Exception:
            pass
        super().closeEvent(event)

    def _on_instr_lang(self, code: str, checked: bool):
        # uno o nessuno
        mp = {
            "it": self.act_instr_it,
            "es": self.act_instr_es,
            "en": self.act_instr_en
        }

        if checked:
            for c, a in mp.items():
                if c != code:
                    a.blockSignals(True)
                    a.setChecked(False)
                    a.blockSignals(False)
            self._instructions_lang = code
            self.settings.setValue("instr_lang", code)
            # mostra subito sulla tab corrente
            self._maybe_show_instructions(self.tabs.currentIndex(), force=True)
        else:
            # se l’utente spegne la lingua attiva -> nessuna
            if self._instructions_lang == code:
                self._instructions_lang = None
                self.settings.setValue("instr_lang", "")
                self._instr_last_tab = None

    def _maybe_show_instructions(self, idx: int, force: bool = False):
        if not self._instructions_lang:
            return
        if not self._instr_popup_enabled:
            return

        if (not force) and (self._instr_last_tab == idx):
            return

        self._instr_last_tab = idx
        tab_name = self.tabs.tabText(idx)

        # Istruzioni per CRITERIA
        if self._instructions_lang == "it":                  # ITALIANO
            if tab_name == "CRITERIA":                       #criteria
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>CRITERIA:</b> attiva/disattiva i criteri con un clic.<br>
    Imposta con cura <b>pesi, il numero di ostacoli e i relativi punteggi/penalità del TRIAL.</b><br>
    Se usi <b>Regularity</b> imposta la media da rispettare (es: 24,5 km/h – usa la virgola, non il punto).<br>
    Con il <b>bottone SIM</b> (simulazione) puoi vedere come i pesi influiscono sulla classifica finale:
    confronta cambiando i pesi e controllando in RANKING come ne viene condizionata la classifica;
    quando sei soddisfatto, clic su <b>FINISH</b> per salvare la configurazione.<br>
    Una volta confermata la configurazione delle penalità e punteggi, non sarà possibile modificarla:
    il setup resta bloccato fino al riavvio (scelta voluta per coerenza gara).<br><br>

    <b>Assicurati di:</b>
    <ul style="margin-top:8px;">
        <li><b>Inserire Titolo evento e data</b> (obbligatori; formato gg/mm/aa – es: 02/11/25).</li>
        <li>La data deve <b>obbligatoriamente</b> coincidere con la data della giornata dell’evento.</li>
        <li>I pesi di <b>CRITERIA</b> vengono applicati <b>solo nella colonna FinalScore</b>.</li>
    </ul>

    </div>
    <div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PER MOSTRARE QUESTE ISTRUZIONI.</b></span>
    </div>
</div>
    """
            elif tab_name == "TRIAL":                      # Trial
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>TRIAL:</b> inserisci il numero <b>TEAM</b> dell’equipaggio/auto (nel campo a sinistra) quando si presenta alla prova.<br>
    <b> Comincia </b> con un clic sull'ostacolo per confermarlo e registra <b>KD/RV</b> (paletti abbattuti e retromarcia), usa i pulsanti <b>bicolore</b>
    per i tentativi (rosso = non superato, verde = superato).<br>
    Il <b>Totale Trial</b> del team si aggiorna <b>in tempo reale</b> (è possibile che possa essere negativo).<br><br>
    Quando l’equipaggio termina la prova, premi <b>OK TEAM</b> per passare al team successivo.<br><br>
    Puoi controllare in <b>RANKING</b> la <b>classifica provvisoria</b> usando i tabulatori.<br>
    <b>Se hai concluso un TEAM</b> con <b>OK TEAM (o FINISH)</b> puoi controllare i risultati in TRIAL cliccando il numero Team sulla lista a destra.<br><br>
    Per terminare le prove di TRIAL, fai clic su <b>FINISH</b>.<br>

    <b>Nota:</b> i punti per 1°/2°/3° tentativo, KD (paletti), RV (reverse) e N ostacoli si configurano in <b>CRITERIA</b>.<br>
</div>
    <div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PER MOSTRARE QUESTE ISTRUZIONI.</b></span>
</div>
"""
            elif tab_name == "SCAN":                      # SCAN
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    Inserisci gli equipaggi scansionando il <b>QR</b>
    <span style="font-size:14pt; color:#007fff;">(oppure scrivendo i META a mano*).</span><br>
    Quando hai terminato l’inserimento, premi <b>FINISH</b> (SCAN).<br><br>

    In qualunque momento puoi aprire il tab <b>RANKING</b> e osservare la classifica provvisoria,
    poi tornare in SCAN e inserire/scansionare gli altri equipaggi man mano che arrivano.<br><br>

    <b>CTRL+P</b> stampa la configurazione dei criteri e la classifica parziale (dati calcolati in tempo reale).<br><br>

    <span style="font-size:14pt; color:#007fff;">
    * Se non hai uno scanner (non consigliato): apri il file .csv sul dispositivo Android del partecipante (cartella Download) e copia la prima riga da 49 caratteri (senza spazi).<br>
    Es: 0010911251238161248450054000001830012000000046263
    </span><br><br>

    Per la scansione del QR raccomando uno <b>scanner 2D</b> (anche Bluetooth).<br>
    Verifica lo <b>scanner 2D</b> con un QR di prova (49 caratteri) prima dell’evento.<br>
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PER MOSTRARE QUESTE ISTRUZIONI.</b></span>
</div>
"""
            elif tab_name == "RANKING":                      # RANKING
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>RANKING:</b> controlla le classifiche e, quando vuoi, <b>stampa con Ctrl+P</b>.<br>

    Puoi attivare lo <b>scroll automatico</b> facendo clic sulle intestazioni delle colonne della classifica:
    puoi attivare contemporaneamente anche più colonne (o tutte).<br><br>

    <span style="font-size:14pt; color:#c00;"><b>Consiglio:</b> se possibile, collega il computer a un televisore/monitor grande per mostrare la classifica al pubblico:
    con un cavo <b>HDMI</b> oppure con <b>duplicazione schermo via Wi-Fi</b> (Miracast / Chromecast, se disponibile).</span><br><br>

    <b>Colonne grezze (senza pesi):</b> Accuracy, CAP, Speed, Regularity (ordinate in crescente); TRIAL (in decrescente).<br>

    <b>FinalScore</b> (colonna pesata):<br>
    <b>R = peso_acc·Accuracy + peso_cap·CAP + peso_speed·Speed + Regularity − peso_trial·Trial</b><br><br>

    Ordinamento: <b>crescente</b> (dal meno penalizzato al più penalizzato).<br>
    Mostra <b>1 decimale</b> (arrotondato).<br>
    Le colonne disattivate in <b>CRITERIA</b> non vengono nascoste: mostrano <b>0</b>.<br>
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PER MOSTRARE QUESTE ISTRUZIONI.</b></span>
</div>
"""
            else:
                msg = f"ISTRUZIONI (placeholder)\n\nPagina: {tab_name}\n\n[Testo da inserire più avanti]"
            title = "Istruzioni"

        elif self._instructions_lang == "es":
            if tab_name == "CRITERIA":
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>CRITERIA:</b> activa/desactiva los criterios con un clic.<br>
    Configura con cuidado <b>los pesos, el número de obstáculos y las puntuaciones/penalizaciones del TRIAL.</b><br>
    Si usas <b>Regularity</b>, ajusta la media a respetar (ej.: 24,5 km/h – usa la coma, no el punto).<br>
    Con el <b>botón SIM</b> (simulación) puedes ver cómo los pesos influyen en la clasificación final:
    compara cambiando los pesos y revisando en RANKING cómo queda afectada la clasificación;
    cuando estés satisfecho, haz clic en <b>FINISH</b> para guardar la configuración.<br>
    Una vez confirmada la configuración de penalizaciones y puntuaciones, no será posible modificarla:
    el setup queda bloqueado hasta reiniciar (decisión intencionada por coherencia de la prueba).<br><br>

    <b>Asegúrate de:</b>
    <ul style="margin-top:8px;">
        <li><b>Introducir el título del evento y la fecha</b> (obligatorios; formato dd/mm/aa – ej.: 02/11/25).</li>
        <li>La fecha debe <b>obligatoriamente</b> coincidir con el día del evento.</li>
        <li>Los pesos de <b>CRITERIA</b> se aplican <b>solo en la columna FinalScore</b>.</li>
    </ul>
    </div>
    <div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PARA MOSTRAR ESTAS INSTRUCCIONES.</b></span>
    </div>
</div>
"""
            elif tab_name == "TRIAL":
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>TRIAL:</b> introduce el número de <b>TEAM</b> del equipo/vehículo (en el campo de la izquierda) cuando se presente en la prueba.<br>
    <b> Empieza </b> con un clic en el obstáculo para confirmarlo y registra <b>KD/RV</b> (palos derribados y marcha atrás); usa los botones <b>bicolor</b>
    para los intentos (rojo = no superado, verde = superado).<br>
    El <b>Total Trial</b> del equipo se actualiza <b>en tiempo real</b> (es posible que sea negativo).<br><br>
    Cuando el equipo termine la prueba, pulsa <b>OK TEAM</b> para pasar al siguiente equipo.<br><br>
    Puedes comprobar en <b>RANKING</b> la <b>clasificación provisional</b> usando los tabuladores.<br>
    <b>Si has finalizado un TEAM</b> con <b>OK TEAM (o FINISH)</b>, puedes consultar los resultados en TRIAL haciendo clic en el número del Team en la lista de la derecha.<br><br>
    Para terminar las pruebas de TRIAL, haz clic en <b>FINISH</b>.<br>

    <b>Nota:</b> los puntos del 1º/2º/3º intento, KD (palos), RV (reverse) y el N.º de obstáculos se configuran en <b>CRITERIA</b>.
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PARA MOSTRAR ESTAS INSTRUCCIONES.</b></span>
</div>
"""
            elif tab_name == "SCAN":                      # SCAN
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    Introduce los equipos escaneando el <b>QR</b>
    <span style="font-size:14pt; color:#007fff;">(o escribiendo los META a mano*).</span><br>
    Cuando hayas terminado la entrada de datos, pulsa <b>FINISH</b> (SCAN).<br><br>

    En cualquier momento puedes abrir la pestaña <b>RANKING</b> y ver la clasificación provisional,
    y luego volver a SCAN para introducir/escanear los demás equipos a medida que van llegando.<br><br>

    <b>CTRL+P</b> imprime la configuración de criterios y la clasificación parcial (datos calculados en tiempo real).<br><br>

    <span style="font-size:14pt; color:#007fff;">
    * Si no tienes un escáner (no recomendado): abre el archivo .csv en el dispositivo Android del participante (carpeta Download) y copia la primera línea de 49 caracteres (sin espacios).<br>
    Ej.: 0010911251238161248450054000001830012000000046263
    </span><br><br>

    Para escanear el QR se recomienda un <b>escáner 2D</b> (también Bluetooth).<br>
    Verifica el <b>escáner 2D</b> con un QR de prueba (49 caracteres) antes del evento.<br>
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PARA MOSTRAR ESTAS INSTRUCCIONES.</b></span>
</div>
"""
            elif tab_name == "RANKING":                      # RANKING
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>RANKING:</b> consulta las clasificaciones y, cuando quieras, <b>imprime con Ctrl+P</b>.<br>

    Puedes activar el <b>desplazamiento automático</b> haciendo clic en los encabezados de las columnas de la tabla:
    puedes activar varias columnas a la vez (o todas).<br>

    <span style="font-size:14pt; color:#c00;"><b>Consejo:</b> si es posible, conecta el ordenador a un televisor/monitor grande para mostrar la clasificación al público:
    con un cable <b>HDMI</b> o con <b>duplicación de pantalla por Wi-Fi</b> (Miracast / Chromecast, si está disponible).</span><br><br>

    <b>Columnas “en bruto” (sin pesos):</b> Accuracy, CAP, Speed, Regularity (ordenadas de menor a mayor); TRIAL (de mayor a menor).<br>

    <b>FinalScore</b> (columna ponderada):<br>
    <b>R = peso_acc·Accuracy + peso_cap·CAP + peso_speed·Speed + Regularity − peso_trial·Trial</b><br><br>

    Orden: <b>ascendente</b> (del menos penalizado al más penalizado).<br>
    Muestra <b>1 decimal</b> (redondeado).<br>
    Las columnas desactivadas en <b>CRITERIA</b> no se ocultan: muestran <b>0</b>.
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 PARA MOSTRAR ESTAS INSTRUCCIONES.</b></span>
</div>
"""
            else:
                msg = f"INSTRUCCIONES (placeholder)\n\nPágina: {tab_name}\n\n[Texto a insertar más adelante]"
            title = "Instrucciones"
            
        elif self._instructions_lang == "en":
            if tab_name == "CRITERIA":
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>CRITERIA:</b> enable/disable criteria with a click.<br>
    Carefully set <b>weights, the number of obstacles, and the TRIAL points/penalties.</b><br>
    If you use <b>Regularity</b>, set the target average speed (e.g. 24,5 km/h – use a comma, not a dot).<br>
    <span style="color:#444;"><b>Note:</b> decimals must use a comma (,).</span><br>
    With the <b>SIM button</b> (simulation) you can see how weights affect the final standings:
    compare by changing weights and checking in RANKING how the <b>FinalScore</b> is affected;
    when you are satisfied, click <b>FINISH</b> to save the configuration.<br>
    Once the points/penalties configuration is confirmed, it cannot be modified:
    the setup remains locked until restart (intentional choice for event consistency).<br>

    <b>Make sure to:</b>
    <ul style="margin-top:8px;">
        <li><b>Enter Event title and date</b> (required; format dd/mm/yy – e.g. 02/11/25).</li>
        <li>The date must <b>strictly</b> match the actual event day.</li>
        <li><b>CRITERIA</b> weights are applied <b>only in the FinalScore column</b>.</li>
    </ul>
    </div>
    <div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>CTRL+F1 TO SHOW THESE INSTRUCTIONS.</b></span>
    </div>
</div>
"""
            elif tab_name == "TRIAL":
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>TRIAL:</b> enter the <b>TEAM</b> number of the crew/vehicle (in the left field) when they arrive at the trial area.<br>
    <b> Start </b> with a click on the obstacle to confirm it, then enter <b>KD/RV</b> (knocked-down poles and reverse), and use the <b>two-color</b> buttons
    for the attempts (red = failed, green = passed).<br>
    The team <b>Trial total</b> updates <b>in real time</b> (it may become negative).<br><br>
    When the team finishes the trial, press <b>OK TEAM</b> to move to the next team.<br><br>
    You can check the <b>provisional ranking</b> in <b>RANKING</b> using the tabs.<br>
    <b>If you have closed a TEAM</b> with <b>OK TEAM (or FINISH)</b>, you can review results in TRIAL by clicking the Team number in the list on the right.<br><br>
    To end the TRIAL session, click <b>FINISH</b>.<br>

    <b>Note:</b> points for 1st/2nd/3rd attempt, KD (poles), RV (reverse), and the number of obstacles are configured in <b>CRITERIA</b>.<br>
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 TO SHOW THESE INSTRUCTIONS.</b></span>
</div>
"""
            elif tab_name == "SCAN":                      # SCAN
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    Add teams by scanning the <b>QR</b>
    <span style="font-size:14pt; color:#007fff;">(or by typing the META manually*).</span><br>
    When you have finished entering data, press <b>FINISH</b> (SCAN).<br><br>

    At any time you can open the <b>RANKING</b> tab to view the provisional standings,
    then go back to SCAN and enter/scan the next teams as they arrive.<br><br>

    <b>CTRL+P</b> prints the criteria setup and the partial ranking (data is calculated in real time).<br><br>

    <span style="font-size:14pt; color:#007fff;">
    * If you don’t have a scanner (not recommended): open the .csv file on the participant’s Android device (Download folder) and copy the first line of 49 characters (no spaces).<br>
    Example: 0010911251238161248450054000001830012000000046263
    </span><br><br>

    For QR scanning we recommend a <b>2D scanner</b> (also Bluetooth).<br>
    Test your <b>2D scanner</b> with a sample QR (49 characters) before the event.<br>
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 TO SHOW THESE INSTRUCTIONS.</b></span>
</div>
"""
            elif tab_name == "RANKING":                      # RANKING
                msg = """
<div style="font-size:14pt; line-height:1.35;">
    <b>RANKING:</b> check the standings and, whenever you want, <b>print with Ctrl+P</b>.<br>

    You can enable <b>auto-scrolling</b> by clicking the column headers:
    you can enable multiple columns at the same time (or all of them).<br>

    <span style="font-size:14pt; color:#c00;"><b>Tip:</b> if possible, connect your computer to a wall TV / large monitor to show the ranking to the audience:
    using an <b>HDMI</b> cable or <b>Wi-Fi screen mirroring</b> (Miracast / Chromecast, if available).</span><br><br>

    <b>Raw columns (no weights):</b> Accuracy, CAP, Speed, Regularity (sorted ascending); TRIAL (sorted descending).<br><br>

    <b>FinalScore</b> (weighted column):<br>
    <b>R = weight_acc·Accuracy + weight_cap·CAP + weight_speed·Speed + Regularity − weight_trial·Trial</b><br><br>

    Sorting: <b>ascending</b> (from least penalized to most penalized).<br>
    Shows <b>1 decimal</b> (rounded).<br>
    Columns disabled in <b>CRITERIA</b> are not hidden: they show <b>0</b>.<br>
</div>
<div style="text-align: center;">
    <span style="font-size:14pt; color:#c00;"><b>F1/CTRL+F1 TO SHOW THESE INSTRUCTIONS.</b></span>
</div>
"""
            else:
                msg = f"INSTRUCTIONS (placeholder)\n\nPágina: {tab_name}\n\n[Text to add]"
            title = "Instructions"
            
        # popup NON bloccante (niente OK, niente exec)
        if getattr(self, "_instr_box", None) is None:
            self._instr_box = QMessageBox(self)
            self._instr_box.setTextFormat(Qt.RichText)
            self._instr_box.setStandardButtons(QMessageBox.NoButton)
            self._instr_box.setModal(False)
            self._instr_box.setWindowModality(Qt.NonModal)
            self._instr_box.setFocusPolicy(Qt.NoFocus)
            self._instr_box.setAttribute(Qt.WA_ShowWithoutActivating, True)
            self._instr_box.setWindowFlag(Qt.Tool, True)

            # Ctrl+F1 funziona anche se il focus è sul popup
            # self._instr_box_sc_hide = QShortcut(QKeySequence("Ctrl+F1"), self._instr_box)
            # self._instr_box_sc_hide.setContext(Qt.ApplicationShortcut)
            # self._instr_box_sc_hide.activated.connect(self._instr_popup_hide)

        fw = QApplication.focusWidget()

        self._instr_box.setWindowTitle(title)
        self._instr_box.setText(msg)          # msg sarà HTML
        self._instr_box.show()

        if fw:
            QTimer.singleShot(0, fw.setFocus)

    # ------------------------------------------------------------
    # CRITERIA -> TRIAL setup (obstacle names + points/penalties)
    # ------------------------------------------------------------
    def _criteria_style_obst(self, edit: QLineEdit, active: bool):
        if active:
            edit.setStyleSheet('background:#00e060; color:#000; border:1px solid #444; border-radius:6px; padding:6px 16px; font: bold 15pt "Arial";')
        else:
            edit.setStyleSheet('background:#d0d0d0; color:#666; border:1px solid #444; border-radius:6px; padding:6px 16px; font: bold 15pt "Arial";')

    def _criteria_set_obst_active_count(self, n: int):
        n = max(0, min(9, int(n)))
        self._trial_obst_active_n = n

        trial_on = bool(self.card_trial.button.isChecked())
        for i, e in enumerate(self.trial_name_edits):
            active = trial_on and (i < n)
            self._criteria_style_obst(e, active)
            e.setReadOnly((not active) or (not self.ok_btn.isEnabled()))

            if (not active) and trial_on and self.ok_btn.isEnabled():
                # off: placeholder pulito (solo quando TRIAL è ON e siamo in editing)
                e.clear()

        if n >= 1:
            self.trial_n_spin.setValue(n)

        if hasattr(self, "page_trial"):
            self.page_trial.n_obst = max(1, self.trial_n_spin.value())

    def _criteria_trial_toggled(self, checked: bool):
        if checked:
            self.lbl_trial_title.setStyleSheet('color:#c00; font: bold 26pt "Arial";')
            self.lbl_pp_title.setStyleSheet('color:#c00; font: bold 20pt "Arial";')
        else:
            self.lbl_trial_title.setStyleSheet('color:#999; font: bold 26pt "Arial";')
            self.lbl_pp_title.setStyleSheet('color:#999; font: bold 20pt "Arial";')

        for sp in (self.trial_p1, self.trial_p2, self.trial_p3, self.trial_kd, self.trial_rv):
            sp.setEnabled(bool(checked) and self.ok_btn.isEnabled())

        if checked:
            n = max(1, min(9, int(self.trial_n_spin.value())))
            self._criteria_set_obst_active_count(n)
            for i in range(n):
                if not self.trial_name_edits[i].text().strip():
                    self.trial_name_edits[i].setText(f"{i+1} Obst.")
        else:
            self._criteria_set_obst_active_count(0)

        self._criteria_push_trial_setup()

    def _criteria_obst_clicked(self, idx: int, ev):
        if (not self.ok_btn.isEnabled()) or (not self.card_trial.button.isChecked()):
            return

        n = getattr(self, "_trial_obst_active_n", 0)

        # 1) campo già attivo (verde): NON spegnere, solo seleziona testo
        if idx < n:
            e = self.trial_name_edits[idx]
            e.setFocus()
            e.selectAll()

            # Ctrl+click sull'ULTIMO verde -> undo (spegne l’ultimo)
            if (idx == n - 1) and (idx >= 1) and (ev.modifiers() & Qt.ControlModifier):
                self._criteria_set_obst_active_count(n - 1)
                self._criteria_push_trial_setup()
            return

        # 2) primo grigio dopo l’ultimo -> aggiungi in sequenza
        if idx == n and n < 9:
            self._criteria_set_obst_active_count(n + 1)
            e = self.trial_name_edits[idx]
            e.setFocus()
            e.selectAll()
            self._criteria_push_trial_setup()
            return
        
    def _criteria_obst_context_menu(self, idx: int, global_pos):
        if (not self.ok_btn.isEnabled()) or (not self.card_trial.button.isChecked()):
            return

        n = getattr(self, "_trial_obst_active_n", 0)

        # menu solo se clic destro sull'ULTIMO verde e ci sono almeno 2 ostacoli
        if n <= 1 or idx != (n - 1):
            return

        menu = QMenu(self)
        act_del = menu.addAction("DELETE")
        chosen = menu.exec(global_pos)
        if chosen == act_del:
            self._criteria_set_obst_active_count(n - 1)
            self._criteria_push_trial_setup()


    def _criteria_read_trial_setup(self):
        ap = [int(self.trial_p1.value()), int(self.trial_p2.value()), int(self.trial_p3.value())]
        kd = int(self.trial_kd.value())
        rv = int(self.trial_rv.value())

        names = []
        for i, e in enumerate(self.trial_name_edits):
            t = e.text().strip()
            if not t:
                t = f"{i+1} Obst."
            names.append(t[:12])

        n_obst = max(1, min(9, int(self.trial_n_spin.value())))
        return ap, names, kd, rv, n_obst

    def _criteria_apply_trial_setup(self, ap, names, kd, rv, n_obst: int, locked: bool):
        self.trial_n_spin.setValue(max(1, min(9, int(n_obst))))

        for i, e in enumerate(self.trial_name_edits):
            e.blockSignals(True)
            e.setText((names[i] if i < len(names) else f"{i+1} Obst.")[:12])
            e.blockSignals(False)

        self.trial_p1.blockSignals(True); self.trial_p1.setValue(int(ap[0])); self.trial_p1.blockSignals(False)
        self.trial_p2.blockSignals(True); self.trial_p2.setValue(int(ap[1])); self.trial_p2.blockSignals(False)
        self.trial_p3.blockSignals(True); self.trial_p3.setValue(int(ap[2])); self.trial_p3.blockSignals(False)
        self.trial_kd.blockSignals(True); self.trial_kd.setValue(int(kd)); self.trial_kd.blockSignals(False)
        self.trial_rv.blockSignals(True); self.trial_rv.setValue(int(rv)); self.trial_rv.blockSignals(False)

        if self.card_trial.button.isChecked():
            self._criteria_set_obst_active_count(max(1, int(n_obst)))
        else:
            self._criteria_set_obst_active_count(0)

        if locked:
            for e in self.trial_name_edits:
                e.setReadOnly(True)

        for sp in (self.trial_p1, self.trial_p2, self.trial_p3, self.trial_kd, self.trial_rv):
            sp.setEnabled(not locked)

    def _criteria_push_trial_setup(self, *_):
        if not hasattr(self, "page_trial"):
            return

        ap, names, kd, rv, n_obst = self._criteria_read_trial_setup()

        self.page_trial.load_setup(ap, names)

        self.page_trial.kd_penalty.blockSignals(True)
        self.page_trial.kd_penalty.setValue(kd)
        self.page_trial.kd_penalty.blockSignals(False)

        self.page_trial.rv_penalty.blockSignals(True)
        self.page_trial.rv_penalty.setValue(rv)
        self.page_trial.rv_penalty.blockSignals(False)

        self.page_trial.n_obst = int(n_obst)
        self.page_trial._recompute_all_rows()
        self._on_criteria_changed()

    def _read_criteria_from_ui_old(self):
        def mult(card) -> float:
            # per Accuracy/CAP/Speed/TRIAL: card.control è MultiplierControl
            try:
                return float(card.control.value())
            except Exception:
                return 1.0

        avg = 0.0
        try:
            avg = float(self.card_regularity.control.value())  # QDoubleSpinBox km/h
        except Exception:
            avg = 0.0

        return {
            "accuracy":   {"enabled": self.card_accuracy.button.isChecked(),   "weight": mult(self.card_accuracy)},
            "cap":        {"enabled": self.card_cap.button.isChecked(),        "weight": mult(self.card_cap)},
            "speed":      {"enabled": self.card_speed.button.isChecked(),      "weight": mult(self.card_speed)},
            # regularity: weight fisso 1.0, avg_kmh viene dal campo km/h
            "regularity": {"enabled": self.card_regularity.button.isChecked(), "weight": 1.0, "avg_kmh": avg},
            # trial: peso dal mult + n ostacoli
            "trial":      {"enabled": self.card_trial.button.isChecked(),      "weight": mult(self.card_trial), "n": int(self.trial_n_spin.value())},
        }

    def _criteria_summary_text(self, title: str, allowed_date: str):
        crit = self._read_criteria_from_ui()
        ap, names, kd, rv, n_obst = self._criteria_read_trial_setup()
        lines = []
        lines.append(f"EVENTO: {title}")
        lines.append(f"DATA:   {allowed_date}")
        lines.append("")
        lines.append("CRITERIA (ON / peso):")
        for k, label in (("accuracy","Accuracy"),("cap","CAP"),("speed","Speed"),("regularity","Regularity"),("trial","Trial")):
            if crit.get(k, {}).get("enabled"):
                w = crit[k].get("weight", 1.0)
                if k == "regularity":
                    avg = crit[k].get("avg_kmh", 0.0)
                    lines.append(f"  ✓ {label}  (x{w:g})   avg={avg:g} km/h")
                else:
                    lines.append(f"  ✓ {label}  (x{w:g})")
            else:
                lines.append(f"  · {label}  (off)")
        if crit.get("trial", {}).get("enabled"):
            lines.append("")
            lines.append("TRIAL setup:")
            lines.append(f"  Tentativi: {ap[0]} / {ap[1]} / {ap[2]}")
            lines.append(f"  Penalty KD/RV: {kd} / {rv}")
            lines.append(f"  N ostacoli: {int(n_obst)}")
            lines.append("  Nomi: " + ", ".join([names[i] for i in range(int(n_obst))]))
        return "\n".join(lines)

    def _on_criteria_changed(self, *_):
        if getattr(self, "sim_mode", False):
            self._sim_refresh()

    def _sim_make_base(self):
        import random
        rnd = random.Random(42)
        base = []
        for i in range(1, 21):
            team = f"{i:03d}"
            km_rb = rnd.randint(0, 2500)
            pts = rnd.randint(0, 9999)
            p_skip = rnd.choice([0, 450, 900, 1350, 1800, 2250])
            p_extra = rnd.randint(300, 9999)
            p_cap = rnd.randint(0, 900)
            p_spd = rnd.randint(0, 200)
            sh = rnd.randint(7, 11); sm = rnd.randint(0, 59); ss = rnd.randint(0, 59)
            start_sec = sh*3600 + sm*60 + ss
            delta_sec = rnd.randint(-900, 1800)
            tri_attempts = [rnd.choice([0, 1, 2, 3]) for _ in range(9)]
            tri_kd = [rnd.randint(0, 1) for _ in range(9)]
            tri_rv = [rnd.randint(0, 1) for _ in range(9)]
            base.append({"team":team,"km_rb":km_rb,"pts":pts,"p_skip":p_skip,"p_extra":p_extra,"p_cap":p_cap,"p_spd":p_spd,
                        "start_sec":start_sec,"delta_sec":delta_sec,"tri_attempts":tri_attempts,"tri_kd":tri_kd,"tri_rv":tri_rv})
        return base

    def _sim_refresh(self, init: bool=False):
        if not getattr(self, "sim_mode", False):
            return
        title = (self.header.event_title.text() or "SIM").strip()
        allowed = (self.header.event_date.text() or "010100").strip()
        if init or getattr(self, "_sim_base", None) is None:
            self._sim_base = self._sim_make_base()

        crit = self._read_criteria_from_ui()
        ap, names, kd, rv, n_obst = self._criteria_read_trial_setup()
        n_obst = int(n_obst)

        def hhmmss_from_sec(sec: int) -> str:
            sec = int(sec) % (24*3600)
            h = sec // 3600; m = (sec % 3600)//60; s = sec % 60
            return f"{h:02d}{m:02d}{s:02d}"

        avg_kmh = float(crit.get("regularity", {}).get("avg_kmh", 0.0)) or 1.0
        avgx10 = int(round(avg_kmh * 10))

        teams = {}
        for b in self._sim_base:
            team = b["team"]
            km = int(b["km_rb"])
            dist_km = km / 10.0
            est = int(round(dist_km / avg_kmh * 3600))
            dur = max(0, est + int(b["delta_sec"]))
            start = int(b["start_sec"])
            end = start + dur
            meta = {"team":team,"data":allowed,"ora_P":hhmmss_from_sec(start),"ora_A":hhmmss_from_sec(end),
                    "punti_T":int(b["pts"]),"P_skip":int(b["p_skip"]),"P_extra":int(b["p_extra"]),
                    "P_CAP":int(b["p_cap"]),"PSpeed":int(b["p_spd"]),"km_RB":km,"AVG":f"{avgx10:03d}"}

            points=[0]*9; states=[0]*9
            for j in range(9):
                if j>=n_obst: break
                att=int(b["tri_attempts"][j])
                states[j]=att
                if att in (1,2,3):
                    base_pts=int(ap[att-1])
                    pen=int(b["tri_kd"][j])*int(kd)+int(b["tri_rv"][j])*int(rv)
                    points[j]=base_pts+pen

            teams[team]={"meta":meta,"trial":{"states":states,"points":points,"completed":True},"scanned_at":"SIM"}

        self.db={"event_title":title,"event_date":allowed,"criteria":crit,"teams":teams,
                "trial_setup":{"attempt_points":ap,"obstacle_names":names,"kd_penalty":kd,"rv_penalty":rv},
                "criteria_locked":False,"scan_closed":True,"trial_closed":True,"scan_rules":{"allowed_date":allowed},"scan_count":len(teams)}
        self.db_path=None; self.db_dir=None
        self.scan_count=len(teams); self.scan_started=True; self.trial_finished=True
        self.page_scan.set_scanned_count(self.scan_count)
        try: self.header.counter.setText(f"{self.scan_count:03d}")
        except Exception: pass
        self._rebuild_ranking_basic()
        self._recompute_total_column()

    def on_trial_finish(self):
        if not self.db:
            return
        # chiudi TRIAL a DB e in RAM
        self.trial_finished = True
        self.db["trial_closed"] = True
        if self.db_path:
            save_db_atomic(self.db, self.db_path)

        scan_closed = bool(self.db.get("scan_closed", False))
        crit = self.db.get("criteria", {})
        only_trial = bool(crit.get("trial", {}).get("enabled", False)) and not any(
            crit.get(k, {}).get("enabled", False) for k in ("accuracy","cap","speed","regularity")
        )
        if only_trial:
            self.trial_finished = True
            self.db["trial_closed"] = True
            if self.db_path:
                save_db_atomic(self.db, self.db_path)

            # vai diretto a RANKING
            self.tabs.setTabEnabled(2, False)   # TRIAL
            self.tabs.setTabEnabled(1, False)   # SCAN
            self.tabs.setTabEnabled(3, True)    # RANKING
            self.tabs.setCurrentIndex(3)
            self._enforce_flow_locks()
            return
        # Regole:
        # - Se abbiamo iniziato da TRIAL: abilita SCAN, RANKING ancora off
        # - Se SCAN era già chiuso: vai a RANKING
        self.tabs.setTabEnabled(2, False)            # TRIAL chiuso
        self.tabs.setTabEnabled(1, not scan_closed)  # SCAN solo se non chiuso
        self.tabs.setTabEnabled(3, scan_closed)      # RANKING solo se SCAN chiuso
        self.tabs.setCurrentIndex(3 if scan_closed else 1)

        self._enforce_flow_locks()

    def reset_new_event(self):
        # reset UI per creare un nuovo evento
        self.db = None
        self.db_path = None
        self.scan_count = 0
        self.header.counter.setText("000")

        # sblocca titolo/data (dopo un evento precedente erano read-only + no-focus)
        et = self.header.event_title
        et.clear()
        et.setProperty("required", True)
        et.style().unpolish(et); et.style().polish(et); et.update()
        et.setReadOnly(False)
        et.setFocusPolicy(Qt.StrongFocus)

        ed = self.header.event_date
        ed.clear()
        ed.setProperty("required", True)
        ed.style().unpolish(ed); ed.style().polish(ed); ed.update()
        ed.setReadOnly(False)
        ed.setFocusPolicy(Qt.StrongFocus)

        self.header.event_date.clear()

        self._trial_current_team = None
        self.trial_finished = False
        self.scan_started = False

        self.page_scan.meta_box.clear()
        self.page_scan.scan_input.clear()
        self.page_scan.set_scanned_count(0)
        # --- NEW: reset vero (anche dopo FINISH) ---

        # reset SIM (se era disabilitato dopo FINISH, qui deve tornare disponibile)
        self.sim_mode = False
        self._sim_backup = None
        self._sim_base = None
        try:
            self.sim_btn.blockSignals(True)
            self.sim_btn.setChecked(False)
            self.sim_btn.setEnabled(True)
            self.sim_btn.blockSignals(False)
        except Exception:
            pass

        # pulisci TRIAL / RANKING (evita di vedere TEAM vecchi dopo NEW)
        try:
            if hasattr(self, "page_trial") and hasattr(self.page_trial, "reset_all"):
                self.page_trial.reset_all()
            elif hasattr(self, "page_trial"):
                self.page_trial.set_scanned_teams([])
        except Exception:
            pass

        try:
            if hasattr(self, "page_ranking") and hasattr(self.page_ranking, "clear_all"):
                self.page_ranking.clear_all()
        except Exception:
            pass

        # riabilita CRITERIA, disabilita gli altri tab
        for card in (self.card_accuracy, self.card_cap, self.card_speed, self.card_regularity, self.card_trial):
            card.setEnabled(True)
            # reset valori
            card.button.setChecked(False)
            if card is self.card_regularity:
                card.control.setValue(24.50)
            else:
                card.control.setValue(1.0)
        # --- reset TRIAL setup (così non riparte con 9 ostacoli attivi) ---
        self.trial_n_spin.setValue(1)

        for e in self.trial_name_edits:
            e.blockSignals(True)
            e.clear()
            e.blockSignals(False)

        # (facoltativo ma consigliato) reset valori default punti/penalità
        self.trial_p1.setValue(100)
        self.trial_p2.setValue(60)
        self.trial_p3.setValue(20)
        self.trial_kd.setValue(1)
        self.trial_rv.setValue(3)

        self._criteria_push_trial_setup()            

        self.ok_btn.setEnabled(True)
        self.tabs.setTabEnabled(0, True)
        self.tabs.setTabEnabled(1, False)

        self.tabs.setTabEnabled(2, False)
        self.tabs.setTabEnabled(3, False)
        self.tabs.setCurrentIndex(0)
        self._enforce_flow_locks()

    def lock_criteria_and_open_scan(self):
        # FINISH: conferma criteri, crea DB (o SIM) e porta a SCAN
        title = (self.header.event_title.text() or "").strip()
        date_txt = (self.header.event_date.text() or "").strip()
        allowed = re.sub(r"[^0-9]", "", date_txt)

        if not title:
            QMessageBox.warning(self, "Titolo mancante", "Inserisci il nome evento.")
            self.header.event_title.setFocus()
            return
        if len(allowed) != 6:
            QMessageBox.warning(self, "Data non valida", "Inserisci la data in formato GG/MM/YY.")
            self.header.event_date.setFocus()
            return
        
        # se SIM è attivo, spegnilo prima di salvare il setup reale
        if getattr(self, "sim_mode", False):
            # spegne SIM (richiama anche _on_sim_toggled(False))
            if hasattr(self, "sim_btn") and self.sim_btn.isChecked():
                self.sim_btn.setChecked(False)
            else:
                self._on_sim_toggled(False)

            # azzera la classifica simulata a schermo
            try:
                self.page_ranking.clear_all()
            except Exception:
                pass

        crit = self._read_criteria_from_ui()
        if not any(v.get("enabled", False) for v in crit.values()):
            QMessageBox.warning(self, "Nessun criterio", "Seleziona almeno un criterio.")
            return

        # dialog conferma criteri (sempre)
        if not self._criteria_confirm_dialog(title, allowed):
            return

        # setup TRIAL da CRITERIA
        ap, names, kd, rv, n_obst = self._criteria_read_trial_setup()

        # --- SIM: niente salvataggi su disco ---
        if getattr(self, "sim_mode", False):
            self._sim_refresh()  # aggiorna DB SIM in RAM
            if not self.db:
                return
            self.db["event_title"] = title
            self.db["scan_rules"] = {"allowed_date": allowed}
            self.db["criteria"] = crit
            self.db["trial_setup"] = {
                "attempt_points": ap,
                "obstacle_names": names,
                "kd_penalty": kd,
                "rv_penalty": rv
            }
            self.db["criteria_locked"] = True
            self._criteria_apply_trial_setup(ap, names, kd, rv, n_obst, locked=True)
            self._criteria_push_trial_setup()
            self._enforce_flow_locks()
            self.tabs.setCurrentIndex(3)  # vai a RANKING
            return

        # --- REAL: chiedi dove salvare ---
        start_dir = self.db_dir or os.path.expanduser("~")
        default_name = f"{slugify(title)}.rbr.json"
        path, _ = QFileDialog.getSaveFileName(
            self,
            "Salva database",
            os.path.join(start_dir, default_name),
            "RB Ranking (*.rbr.json)"
        )
        if not path:
            return
        if not path.lower().endswith(".rbr.json"):
            path += ".rbr.json"

        self.db = {
            "version": 1,
            "event_title": title,
            "criteria": crit,
            "criteria_locked": True,
            "scan_rules": {"allowed_date": allowed},
            "trial_setup": {
                "attempt_points": ap,
                "obstacle_names": names,
                "kd_penalty": kd,
                "rv_penalty": rv
            },
            "teams": {},
            "scan_count": 0,
            "scan_closed": False,
            "trial_closed": False,
            "created_at": datetime.now().isoformat(timespec="seconds")
        }

        self.db_path = path
        self.db_dir = os.path.dirname(path)
        self.scan_count = 0
        self.scan_started = False
        self.trial_finished = False

        save_db_atomic(self.db, self.db_path)

        # lock titolo/data
        et = self.header.event_title
        et.setProperty("required", False)
        et.setReadOnly(True)
        et.setFocusPolicy(Qt.NoFocus)

        ed = self.header.event_date
        ed.setText(f"{allowed[:2]}/{allowed[2:4]}/{allowed[4:]}")
        ed.setProperty("required", False)
        ed.setReadOnly(True)
        ed.setFocusPolicy(Qt.NoFocus)

        self._criteria_apply_trial_setup(ap, names, kd, rv, n_obst, locked=True)
        self._criteria_push_trial_setup()

        self._enforce_flow_locks()
        self.tabs.setCurrentIndex(1)  # vai a SCAN
        self.page_scan.scan_input.setEnabled(True)
        QTimer.singleShot(0, lambda: self.page_scan.scan_input.setFocus())  

    def check_for_update(self):
        box = QMessageBox(self)
        box.setWindowTitle("Check for update")
        box.setText(
            f"Installed version: {APP_VERSION}\n\n"
            "To see if a newer version is available, open the official page."
        )
        btn = box.addButton("Open page", QMessageBox.AcceptRole)
        box.addButton("Close", QMessageBox.RejectRole)
        box.exec()

        if box.clickedButton() == btn:
            QDesktopServices.openUrl(QUrl("https://roadbook-system.com/rb-ranking.html"))

    def open_db_dialog(self):
        # scegli e carica un .rbr.json
        start_dir = self.db_dir or (self.db_path and os.path.dirname(self.db_path)) or os.path.expanduser("~")
        path, _ = QFileDialog.getOpenFileName(self, "Apri database", start_dir, "RB Ranking (*.rbr.json)")

        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8") as f:
                db = json.load(f)
        except Exception as e:
            self.page_scan.meta_box.setText(f"Errore apertura DB: {e}")
            return

        # memorizza
        self.db = db
        self.trial_finished = bool(self.db.get("trial_closed", False))
        self.db_path = path
        self.db_dir = os.path.dirname(path)
        self.scan_count = int(db.get("scan_count", 0))
        scan_closed = bool(db.get("scan_closed", False))
        self.page_scan.finish_btn.setEnabled((self.scan_count > 0) and (not scan_closed))
        self.scan_started = (self.scan_count > 0)

        # UI: titolo e contatore
        self.header.event_title.setText(db.get("event_title", ""))
        self.header.counter.setText(f"{self.scan_count:03d}") 

        # popola la lista "SCANNED" dalla DB aperto
        self._migrate_db()

        completed = [k for k, v in self.db.get("teams", {}).items()
                    if v.get("trial", {}).get("completed")]
        self.page_trial.set_scanned_teams(completed)
        self.page_trial.mark_completed(completed)

        # blocca CRITERIA e popola i valori
        crit = db.get("criteria", {})
        self.card_accuracy.button.setChecked(bool(crit.get("accuracy", {}).get("enabled", False)))
        self.card_cap.button.setChecked(bool(crit.get("cap", {}).get("enabled", False)))
        self.card_speed.button.setChecked(bool(crit.get("speed", {}).get("enabled", False)))
        self.card_regularity.button.setChecked(bool(crit.get("regularity", {}).get("enabled", False)))
        self.card_trial.button.setChecked(bool(crit.get("trial", {}).get("enabled", False)))

        self.card_accuracy.control.setValue(float(crit.get("accuracy",   {}).get("weight", 1.0)))
        self.card_cap.control.setValue(     float(crit.get("cap",        {}).get("weight", 1.0)))
        self.card_speed.control.setValue(   float(crit.get("speed",      {}).get("weight", 1.0)))
        self.card_trial.control.setValue(   float(crit.get("trial",      {}).get("weight", 1.0)))
        self.card_regularity.control.setValue(float(crit.get("regularity",{}).get("avg_kmh", 0.0)))

        n_val = int(crit.get("trial", {}).get("n", 9))
        self.trial_n_spin.setValue(n_val)
        self.trial_n_spin.setEnabled(False)   # DB aperto: criteri lockati

        # applica setup TRIAL dalla DB alla pagina
        ts = db.get("trial_setup", {})
        ap = ts.get("attempt_points", [100, 60, 20])
        names = ts.get("obstacle_names", [f"{i} Obst." for i in range(1, 10)])
        kd = int(ts.get("kd_penalty", 1))
        rv = int(ts.get("rv_penalty", 3))

        self.page_trial.load_setup(ap, names)
        self.page_trial.kd_penalty.setValue(kd)
        self.page_trial.rv_penalty.setValue(rv)
        self.page_trial.n_obst = n_val

        scan_rules = db.get("scan_rules", {})
        ad = scan_rules.get("allowed_date", "")
        if ad and len(ad) == 6:
            self.header.event_date.setText(f"{ad[:2]}/{ad[2:4]}/{ad[4:]}")
            self.header.event_date.setProperty("required", False)
            self.header.event_date.setReadOnly(True)
            self.header.event_date.setFocusPolicy(Qt.NoFocus)

        # blocca sempre CRITERIA (DB già fissato)
        for card in (self.card_accuracy, self.card_cap, self.card_speed, self.card_regularity, self.card_trial):
            card.setEnabled(False)
        self.ok_btn.setEnabled(False)
        self.sim_btn.setEnabled(False)
        self.sim_btn.setChecked(False)

        # assicura lock dei campi TRIAL in CRITERIA
        self._criteria_apply_trial_setup(ap, names, kd, rv, n_val, locked=True)

        # abilita i tab in base allo stato dello scan
        scan_closed = bool(db.get("scan_closed", False))
        trial_on = bool(crit.get("trial", {}).get("enabled", False))
        self.trial_finished = bool(self.db.get("trial_closed", False))

        # base
        self.tabs.setTabEnabled(0, False)
        self.tabs.setTabEnabled(1, not scan_closed)
        self.tabs.setTabEnabled(2, trial_on and not self.trial_finished)
        self.tabs.setTabEnabled(3, scan_closed and (not trial_on or self.trial_finished))

        # atterraggio: se TRIAL previsto e non finito → TRIAL, altrimenti SCAN/RANKING
        if trial_on and not self.trial_finished:
            self.tabs.setCurrentIndex(2)
        elif scan_closed:
            self.tabs.setCurrentIndex(3)
        else:
            self.tabs.setCurrentIndex(1)

        self._enforce_flow_locks()
        if self.tabs.currentIndex() == 1:
            self.page_scan.set_scanned_count(self.scan_count)
            QTimer.singleShot(0, lambda: self.page_scan.scan_input.setFocus())

    def _enforce_flow_locks(self):
        # TAB sempre cliccabili
        for i in range(4):
            self.tabs.setTabEnabled(i, True)

        # nessun DB: mostra tab ma blocca input
        if not self.db:
            self.page_scan.scan_input.setEnabled(False)
            self.page_scan.finish_btn.setEnabled(False)
            self.page_trial.setEnabled(False)
            return

        sim = bool(getattr(self, "sim_mode", False))
        crit = self.db.get("criteria", {})
        trial_on = bool(crit.get("trial", {}).get("enabled", False))
        scan_closed = bool(self.db.get("scan_closed", False))
        trial_closed = bool(self.db.get("trial_closed", False))
        locked = bool(self.db.get("criteria_locked", False))

        for card in (self.card_accuracy, self.card_cap, self.card_speed, self.card_regularity, self.card_trial):
            card.setEnabled(not locked)
        self.ok_btn.setEnabled(not locked)
        if hasattr(self, "sim_btn"):
            self.sim_btn.setEnabled(not locked)

        scan_enabled = (not scan_closed) and (not sim)
        self.page_scan.scan_input.setEnabled(scan_enabled)
        if not scan_enabled:
            self.page_scan.finish_btn.setEnabled(False)

        # TRIAL: abilitata anche dopo FINISH (view-only), ma mai in SIM
        trial_page_on = trial_on and (not sim)
        self.page_trial.setEnabled(trial_page_on)

        # dopo FINISH: niente inserimenti nuovi (TEAM read-only) ma lista cliccabile per consultare
        if trial_page_on:
            try:
                self.page_trial.team_big.setReadOnly(trial_closed)
                self.page_trial.team_big.setFocusPolicy(Qt.NoFocus if trial_closed else Qt.StrongFocus)
            except Exception:
                pass

        if trial_page_on and trial_closed:
            try:
                self.page_trial.lock_trial_inputs_viewonly()
            except Exception:
                pass

        # se TRIAL è chiuso: lascia la pagina cliccabile (per review), ma blocca input
        if trial_on and (not sim) and trial_closed:
            try:
                self.page_trial.lock_trial_inputs_viewonly()
                self.page_trial.ok_team_btn.setEnabled(False)
                self.page_trial.finish_trial_btn.setEnabled(False)
                self.page_trial.team_big.setEnabled(False)  # evita inserimenti nuovi dopo FINISH
            except Exception:
                pass
        else:
            # quando non è chiuso, riabilita l’input TEAM (se era rimasto OFF)
            try:
                self.page_trial.team_big.setEnabled(True)
            except Exception:
                pass

    def _hline(self):
        line = QFrame()
        line.setFrameShape(QFrame.HLine)
        line.setFrameShadow(QFrame.Plain)
        line.setStyleSheet("color:#333;")
        return line

    # ---- flow ----
    def _read_criteria_from_ui(self):
        def mult(card) -> float:
            # per Accuracy/CAP/Speed/TRIAL: il moltiplicatore sta in card.control (MultiplierControl)
            try:
                return float(card.control.value())
            except Exception:
                return 1.0

        # Regularity: qui card_regularity.control è la QDoubleSpinBox (km/h)
        try:
            avg = float(self.card_regularity.control.value())
        except Exception:
            avg = 0.0

        return {
            "accuracy":   {"enabled": self.card_accuracy.button.isChecked(),   "weight": mult(self.card_accuracy)},
            "cap":        {"enabled": self.card_cap.button.isChecked(),        "weight": mult(self.card_cap)},
            "speed":      {"enabled": self.card_speed.button.isChecked(),      "weight": mult(self.card_speed)},
            "regularity": {"enabled": self.card_regularity.button.isChecked(), "weight": 1.0, "avg_kmh": avg},
            "trial":      {"enabled": self.card_trial.button.isChecked(),      "weight": mult(self.card_trial), "n": int(self.trial_n_spin.value())},
        }

    def _criteria_summary_text(self, title: str, allowed_date: str):
        crit = self._read_criteria_from_ui()
        ap, names, kd, rv, n_obst = self._criteria_read_trial_setup()
        lines = []
        lines.append(f"EVENTO: {title}")
        lines.append(f"DATA:   {allowed_date}")
        lines.append("")
        lines.append("CRITERIA (ON / peso):")
        for k, label in (("accuracy","Accuracy"),("cap","CAP"),("speed","Speed"),("regularity","Regularity"),("trial","Trial")):
            if crit.get(k, {}).get("enabled"):
                w = crit[k].get("weight", 1.0)
                if k == "regularity":
                    avg = crit[k].get("avg_kmh", 0.0)
                    lines.append(f"  ✓ {label}  (x{w:g})   avg={avg:g} km/h")
                else:
                    lines.append(f"  ✓ {label}  (x{w:g})")
            else:
                lines.append(f"  · {label}  (off)")
        if crit.get("trial", {}).get("enabled"):
            lines.append("")
            lines.append("TRIAL setup:")
            lines.append(f"  Tentativi: {ap[0]} / {ap[1]} / {ap[2]}")
            lines.append(f"  Penalty KD/RV: {kd} / {rv}")
            lines.append(f"  N ostacoli: {int(n_obst)}")
            lines.append("  Nomi: " + ", ".join([names[i] for i in range(int(n_obst))]))
        return "\n".join(lines)

    def _criteria_confirm_dialog(self, title: str, allowed_date: str) -> bool:
        summary = self._criteria_summary_text(title, allowed_date)
        box = QMessageBox(self)
        box.setIcon(QMessageBox.Question)
        box.setWindowTitle("Conferma criteri")
        box.setText("Confermi il setup di CRITERIA?")
        box.setDetailedText(summary)
        box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
        return box.exec() == QMessageBox.Ok

    def _on_criteria_changed(self, *_):
        if getattr(self, "sim_mode", False):
            self._sim_refresh()

    def _on_sim_toggled(self, checked: bool):
        if checked and self.db and bool(self.db.get("criteria_locked", False)):
            self.sim_btn.blockSignals(True)
            self.sim_btn.setChecked(False)
            self.sim_btn.blockSignals(False)
            QMessageBox.information(self, "SIM", "SIM non disponibile dopo FINISH.")
            return

        if checked:
            self.sim_mode = True
            self._sim_backup = (self.db, self.db_path, self.db_dir, self.scan_count, self.scan_started, self.trial_finished)
            self._sim_base = None
            self._sim_refresh(init=True)
            self._enforce_flow_locks()
            self.tabs.setCurrentIndex(3)
        else:
            self.sim_mode = False
            # pulisci la tabella Ranking (era simulata)
            try:
                if hasattr(self, "page_ranking") and hasattr(self.page_ranking, "clear_all"):
                    self.page_ranking.clear_all()
            except Exception:
                pass

            if getattr(self, "_sim_backup", None) is not None:
                self.db, self.db_path, self.db_dir, self.scan_count, self.scan_started, self.trial_finished = self._sim_backup
            else:
                self.db = None
                self.db_path = None
                self.db_dir = None
                self.scan_count = 0
                self.scan_started = False
                self.trial_finished = False
            # UI: ripristina contatore + label SCAN
            cnt = int(self.scan_count or 0)
            try:
                if self.db and isinstance(self.db, dict):
                    cnt = int(self.db.get("scan_count", cnt) or 0)
            except Exception:
                pass
            self.scan_count = cnt

            try:
                self.header.counter.setText(f"{cnt:03d}")
            except Exception:
                pass
            try:
                self.page_scan.set_scanned_count(cnt)
            except Exception:
                pass
            self._sim_backup = None
            self._sim_base = None
            self._enforce_flow_locks()
            self.tabs.setCurrentIndex(0)

    def _sim_make_base(self):
        import random
        rnd = random.Random(42)
        base = []
        for i in range(1, 21):
            team = f"{i:03d}"
            km_rb = rnd.randint(0, 2500)
            pts = rnd.randint(0, 9999)
            p_skip = rnd.choice([0, 450, 900, 1350, 1800, 2250])
            p_extra = rnd.randint(300, 9999)
            p_cap = rnd.randint(0, 900)
            p_spd = rnd.randint(0, 200)
            sh = rnd.randint(7, 11); sm = rnd.randint(0, 59); ss = rnd.randint(0, 59)
            start_sec = sh*3600 + sm*60 + ss
            delta_sec = rnd.randint(-900, 1800)
            tri_attempts = [rnd.choice([0, 1, 2, 3]) for _ in range(9)]
            tri_kd = [rnd.randint(0, 1) for _ in range(9)]
            tri_rv = [rnd.randint(0, 1) for _ in range(9)]
            base.append({"team":team,"km_rb":km_rb,"pts":pts,"p_skip":p_skip,"p_extra":p_extra,"p_cap":p_cap,"p_spd":p_spd,
                        "start_sec":start_sec,"delta_sec":delta_sec,"tri_attempts":tri_attempts,"tri_kd":tri_kd,"tri_rv":tri_rv})
        return base

    def _sim_refresh(self, init: bool=False):
        if not getattr(self, "sim_mode", False):
            return
        title = (self.header.event_title.text() or "SIM").strip()
        allowed = (self.header.event_date.text() or "010100").strip()
        if init or getattr(self, "_sim_base", None) is None:
            self._sim_base = self._sim_make_base()

        crit = self._read_criteria_from_ui()
        ap, names, kd, rv, n_obst = self._criteria_read_trial_setup()
        n_obst = int(n_obst)

        def hhmmss_from_sec(sec: int) -> str:
            sec = int(sec) % (24*3600)
            h = sec // 3600; m = (sec % 3600)//60; s = sec % 60
            return f"{h:02d}{m:02d}{s:02d}"

        avg_kmh = float(crit.get("regularity", {}).get("avg_kmh", 0.0)) or 1.0
        avgx10 = int(round(avg_kmh * 10))

        teams = {}
        for b in self._sim_base:
            team = b["team"]
            km = int(b["km_rb"])
            dist_km = km / 10.0
            est = int(round(dist_km / avg_kmh * 3600))
            dur = max(0, est + int(b["delta_sec"]))
            start = int(b["start_sec"])
            end = start + dur
            meta = {"team":team,"data":allowed,"ora_P":hhmmss_from_sec(start),"ora_A":hhmmss_from_sec(end),
                    "punti_T":int(b["pts"]),"P_skip":int(b["p_skip"]),"P_extra":int(b["p_extra"]),
                    "P_CAP":int(b["p_cap"]),"PSpeed":int(b["p_spd"]),"km_RB":km,"AVG":f"{avgx10:03d}"}

            points=[0]*9; states=[0]*9
            for j in range(9):
                if j>=n_obst: break
                att=int(b["tri_attempts"][j])
                states[j]=att
                if att in (1,2,3):
                    base_pts=int(ap[att-1])
                    pen=int(b["tri_kd"][j])*int(kd)+int(b["tri_rv"][j])*int(rv)
                    points[j]=base_pts+pen

            teams[team]={"meta":meta,"trial":{"states":states,"points":points,"completed":True},"scanned_at":"SIM"}

        self.db={"event_title":title,"event_date":allowed,"criteria":crit,"teams":teams,
                "trial_setup":{"attempt_points":ap,"obstacle_names":names,"kd_penalty":kd,"rv_penalty":rv},
                "criteria_locked":False,"scan_closed":True,"trial_closed":True,"scan_rules":{"allowed_date":allowed},"scan_count":len(teams)}
        self.db_path=None; self.db_dir=None
        self.scan_count=len(teams); self.scan_started=True; self.trial_finished=True
        self.page_scan.set_scanned_count(self.scan_count)
        try: self.header.counter.setText(f"{self.scan_count:03d}")
        except Exception: pass
        self._rebuild_ranking_basic()
        self._recompute_total_column()

    def on_scan(self, meta_text: str):
        try:
            rec = parse_meta_line(meta_text)
        except Exception as e:
            self.page_scan.meta_box.setText(f"ERRORE: {e}")
            return

        # --- filtro DATA EVENTO (accetta solo la data impostata in CRITERIA) ---
        allowed = (self.db or {}).get("scan_rules", {}).get("allowed_date")
        rec_date = rec.get("data")
        if allowed and rec_date and rec_date != allowed:
            self.page_scan.meta_box.setText(f"DATA NON VALIDA ({rec_date} ≠ {allowed}) — QR rifiutato")
            return
        # -----------------------------------------------------------------------

        team = str(rec.get("team", "")).strip()
        team = team.lstrip("0") or "0"
        tdict = self.db["teams"].setdefault(team, {})

        # già scansionato SOLO se ha scanned_at
        if tdict.get("scanned_at"):
            self.page_scan.meta_box.setText(f"TEAM {team} già scansionato")
            return

        # unisci meta senza toccare eventuali dati TRIAL
        tdict["meta"] = rec
        tri = tdict.setdefault("trial", {})
        if "scores" in tri and "points" not in tri:
            tri["points"] = list(tri["scores"])
            tri.pop("scores", None)
        tri.setdefault("states", [0]*9)
        tri.setdefault("points", [0]*9)

        # marca come scansionato
        tdict["scanned_at"] = datetime.now().isoformat(timespec="seconds")

        # conta SOLO i team con scanned_at
        self.db["scan_count"] = sum(1 for v in self.db["teams"].values() if v.get("scanned_at"))
        if self.db_path:
            save_db_atomic(self.db, self.db_path)

        # UI
        self.scan_count = self.db["scan_count"]
        self.page_scan.set_scanned_count(self.scan_count)
        self.header.counter.setText(f"{self.scan_count:03d}")
        self.scan_started = True
        self._enforce_flow_locks()

    def _on_tab_changed(self, idx: int):
        self._enforce_flow_locks()
        page = self.tabs.widget(idx)
        if page is self.page_ranking:
            self._rebuild_ranking_trial()
            self._rebuild_ranking_basic()
            self._rebuild_ranking_regularity()
            self._rebuild_ranking_total()
        if page is self.page_scan:
            self.page_scan.scan_input.setFocus()

        # popup istruzioni (se attive)
        self._maybe_show_instructions(idx)

        # ribadisci lo stato max subito dopo il relayout della tab
        # QTimer.singleShot(0, self.showMaximized)
        # QTimer.singleShot(1, self.showMaximized)

    def _rebuild_ranking_trial(self):
        """Costruisce la colonna 'Trial' in RANKING dai dati nel DB."""
        if not getattr(self, "db", None):
            self.page_ranking.clear_all()
            return

        def _tkey(t):
            try:
                return int(str(t))
            except Exception:
                return str(t)

        crit = self.db.get("criteria", {}) or {}
        if not bool(crit.get("trial", {}).get("enabled", False)):
            rows = [(team, 0) for team in (self.db.get("teams", {}) or {}).keys()]
            rows.sort(key=lambda x: _tkey(x[0]))
            self.page_ranking.set_column("Trial", rows)
            return

        n = int(crit.get("trial", {}).get("n", 9))
        rows = []
        for team, rec in (self.db.get("teams", {}) or {}).items():
            tri = rec.get("trial", {}) or {}
            pts_list = [int(x) for x in tri.get("points", [])][:n] if tri else []
            total = sum(pts_list) if pts_list else 0
            rows.append((team, total))

        rows.sort(key=lambda x: x[1], reverse=True)  # punti più alti in alto
        self.page_ranking.set_column("Trial", rows)

    def _rebuild_ranking_basic(self):
        """Costruisce le colonne Accuracy/CAP/Speed dai META (ordine: penalità crescenti)."""
        if not getattr(self, "db", None):
            return

        crit = self.db.get("criteria", {}) or {}
        ena_acc = bool(crit.get("accuracy", {}).get("enabled", False))
        ena_cap = bool(crit.get("cap", {}).get("enabled", False))
        ena_spd = bool(crit.get("speed", {}).get("enabled", False))

        teams = self.db.get("teams", {}) or {}
        acc_rows, cap_rows, spd_rows = [], [], []

        for team, rec in teams.items():
            meta = rec.get("meta", {}) or {}

            acc_val = (
                int(meta.get("punti_T", 0) or 0)
                + int(meta.get("P_skip", 0) or 0)
                + int(meta.get("P_extra", 0) or 0)
            ) if ena_acc else 0

            cap_val = int(meta.get("P_CAP", 0) or 0) if ena_cap else 0
            spd_val = int(meta.get("PSpeed", 0) or 0) if ena_spd else 0

            acc_rows.append((team, acc_val))
            cap_rows.append((team, cap_val))
            spd_rows.append((team, spd_val))

        acc_rows.sort(key=lambda x: x[1])
        cap_rows.sort(key=lambda x: x[1])
        spd_rows.sort(key=lambda x: x[1])

        self.page_ranking.set_column("Accuracy", acc_rows)
        self.page_ranking.set_column("CAP",      cap_rows)
        self.page_ranking.set_column("Speed",    spd_rows)

    def _rebuild_ranking_regularity(self):
        """Colonna Regularity: secondi di anticipo + max(ritardo-59, 0).
        Tempi da meta: ora_P / ora_A (HHMMSS), distanza km_RB.
        Media: criteria.regularity.avg_kmh, altrimenti meta.AVG/10.
        """
        if not getattr(self, "db", None):
            return
        crit = self.db.get("criteria", {}) or {}
        if not bool(crit.get("regularity", {}).get("enabled", False)):
            def _tkey(t):
                try:
                    return int(str(t))
                except Exception:
                    return str(t)
            rows = [(team, 0) for team in (self.db.get("teams", {}) or {}).keys()]
            rows.sort(key=lambda x: _tkey(x[0]))
            self.page_ranking.set_column("Regularity", rows)
            return

        def _hhmmss_to_sec(s):
            try:
                s = str(s).strip()
                if len(s) != 6 or not s.isdigit():
                    return None
                h, m, sec = int(s[:2]), int(s[2:4]), int(s[4:6])
                return h*3600 + m*60 + sec
            except Exception:
                return None

        avg_kmh_criteria = float(self.db.get("criteria", {}).get("regularity", {}).get("avg_kmh", 0.0))
        rows = []

        for team, rec in (self.db.get("teams", {}) or {}).items():
            meta = rec.get("meta", {}) or {}

            # 1) se esiste già una penalità pronta nel meta, usala
            for k in ("P_REG","PReg","PRegularity","REG_PEN","pen_reg"):
                if k in meta:
                    try:
                        rows.append((team, int(meta[k])))
                        break
                    except Exception:
                        pass
            else:
                # 2) calcolo da tempi e distanza
                start = _hhmmss_to_sec(meta.get("ora_P"))
                end   = _hhmmss_to_sec(meta.get("ora_A"))
                try:
                    km = float(meta.get("km_RB", 0)) / 10.0   # deci-km → km
                except Exception:
                    km = 0.0
                avg_kmh = avg_kmh_criteria or (float(meta.get("AVG", 0)) / 10.0)

                pen = 0
                if start is not None and end is not None and km > 0 and avg_kmh > 0:
                    actual = end - start
                    if actual < 0:
                        actual += 24*3600
                    expected = int(round(3600.0 * km / avg_kmh))
                    delta = actual - expected
                    late  = delta if delta > 0 else 0
                    early = -delta if delta < 0 else 0
                    pen = int(early + max(late - 59, 0))

                rows.append((team, pen))

        rows.sort(key=lambda x: x[1])   # penalità minori in alto
        self.page_ranking.set_column("Regularity", rows)

    def _recompute_total_column(self):
        """Compat: ricostruisce tutte le colonne del tab RANKING."""
        if not getattr(self, "db", None):
            return
        self._rebuild_ranking_trial()
        self._rebuild_ranking_basic()
        self._rebuild_ranking_regularity()
        self._rebuild_ranking_total()

    def _rebuild_ranking_total(self):
        """Prima colonna: (w_acc*ACC + w_cap*CAP + w_spd*SPEED + w_reg*REG) - w_trial*TRIAL."""
        if not getattr(self, "db", None):
            return

        crit = self.db.get("criteria", {})
        w_acc   = float(crit.get("accuracy",   {}).get("weight", 1.0)) if crit.get("accuracy",   {}).get("enabled", False) else 0.0
        w_cap   = float(crit.get("cap",        {}).get("weight", 1.0)) if crit.get("cap",        {}).get("enabled", False) else 0.0
        w_spd   = float(crit.get("speed",      {}).get("weight", 1.0)) if crit.get("speed",      {}).get("enabled", False) else 0.0
        w_reg   = float(crit.get("regularity", {}).get("weight", 1.0)) if crit.get("regularity", {}).get("enabled", False) else 0.0
        w_trial = float(crit.get("trial",      {}).get("weight", 1.0)) if crit.get("trial",      {}).get("enabled", False) else 0.0
        n = int(crit.get("trial", {}).get("n", 9))

        def reg_pen(meta: dict) -> int:
            if not meta:
                return 0
            for k in ("P_REG","PReg","PRegularity","REG_PEN","pen_reg"):
                if k in meta:
                    try:
                        return int(meta[k])
                    except Exception:
                        pass

            def _hhmmss_to_sec(s):
                try:
                    s = str(s).strip()
                    if len(s) != 6 or not s.isdigit():
                        return None
                    return int(s[:2])*3600 + int(s[2:4])*60 + int(s[4:6])
                except Exception:
                    return None

            start = _hhmmss_to_sec(meta.get("ora_P"))
            end   = _hhmmss_to_sec(meta.get("ora_A"))
            try:
                km = float(meta.get("km_RB", 0)) / 10.0   # deci-km → km
            except Exception:
                km = 0.0
            avg_kmh = float(crit.get("regularity", {}).get("avg_kmh", 0.0)) or (float(meta.get("AVG", 0))/10.0)
            if start is None or end is None or km <= 0 or avg_kmh <= 0:
                return 0

            actual = end - start
            if actual < 0:
                actual += 24*3600
            expected = int(round(3600.0 * km / avg_kmh))
            delta = actual - expected
            late  = delta if delta > 0 else 0
            early = -delta if delta < 0 else 0
            return int(early + max(late - 59, 0))


        rows = []
        for team, rec in (self.db.get("teams", {}) or {}).items():
            meta = rec.get("meta", {}) or {}
            acc = int(meta.get("punti_T", 0) or 0) + int(meta.get("P_skip", 0) or 0) + int(meta.get("P_extra", 0) or 0)
            cap = int(meta.get("P_CAP",   0) or 0)
            spd = int(meta.get("PSpeed",  0) or 0)
            reg = reg_pen(meta)

            tri_pts = 0
            tri = rec.get("trial", {}) or {}
            pts_list = [int(x) for x in tri.get("points", [])][:n] if tri else []
            tri_pts = sum(pts_list) if pts_list else 0

            total = (w_acc*acc) + (w_cap*cap) + (w_spd*spd) + (w_reg*reg) - (w_trial*tri_pts)
            rows.append((team, round(total, 2)))

        rows.sort(key=lambda x: x[1])  # min migliore
        self.page_ranking.set_column("FinalScore", rows)

    def changeEvent(self, e):
        super().changeEvent(e)
        if e.type() == QEvent.WindowStateChange and not self.isMaximized():
            QTimer.singleShot(0, self.showMaximized)

    def enable_trial_and_ranking(self):
        if not self.db or self.scan_count == 0:
            QMessageBox.warning(self, "Nessun equipaggio", "Scansiona almeno un equipaggio prima di concludere lo SCAN.")
            return

        # chiudi definitivamente lo SCAN
        self.db["scan_closed"] = True
        save_db_atomic(self.db, self.db_path)

        trial_on = bool(self.db.get("criteria", {}).get("trial", {}).get("enabled", False))
        # Se TRIAL è previsto: abilita TRIAL; RANKING solo se TRIAL è già finito
        self.tabs.setTabEnabled(0, False)                     # CRITERIA
        self.tabs.setTabEnabled(1, False)                     # SCAN
        self.tabs.setTabEnabled(2, trial_on and not self.trial_finished)
        self.tabs.setTabEnabled(3, (not trial_on) or self.trial_finished)
        self.tabs.setCurrentIndex(2 if (trial_on and not self.trial_finished) else 3)

        self._enforce_flow_locks()

    def on_trial_team_change(self, team: str, only_setup: bool=False):
        if not self.db:
            return

        # salva SEMPRE il setup (anche senza team)
        ap, names = self.page_trial.read_setup()
        self.db["trial_setup"]["attempt_points"] = ap
        self.db["trial_setup"]["obstacle_names"] = names
        self.db["trial_setup"]["kd_penalty"] = int(self.page_trial.kd_penalty.value())
        self.db["trial_setup"]["rv_penalty"] = int(self.page_trial.rv_penalty.value())
        save_db_atomic(self.db, self.db_path)

        if only_setup:
            return

        key = team.lstrip("0") or "0"
        tdict = self.db["teams"].setdefault(key, {})
        tri = tdict.setdefault("trial", {})
        tri.setdefault("states", [0]*9)
        tri.setdefault("points", [0]*9)

        if tri.get("completed"):
            self.page_trial.mark_completed([key])
            self._trial_current_team = (team or "").strip()
            self.page_trial.init_rows_from_team(tri["states"], tri["points"], tri.get("kd"), tri.get("rv"))
            self.page_trial.load_team_points(tri["points"])
            return

        self._trial_current_team = (team or "").strip()
        self.page_trial.init_rows_from_team(tri["states"], tri["points"], tri.get("kd"), tri.get("rv"))
        self.page_trial.load_team_points(tri["points"])
        save_db_atomic(self.db, self.db_path)

        try:
            self.header.counter.setText(f"{len(self.db['teams']):03d}")
        except Exception:
            pass

    def on_trial_row_update(self, row_idx: int, state: int, points: int):
        """Chiamato da TrialPage quando una riga viene conclusa (passata o 3 fallimenti)."""
        if not self.db:
            return
        team = (self.page_trial.team_edit.text() or "").strip()
        if not team:
            return

        tdict = self.db["teams"].setdefault(team.lstrip("0") or "0", {})
        tri = tdict.setdefault("trial", {})
        tri.setdefault("states", [0]*9)
        tri.setdefault("points", [0]*9)
        tri.setdefault("kd", [0]*9)
        tri.setdefault("rv", [0]*9)

        # se già concluso, ignora l'update (niente avviso qui)
        if tri.get("completed"):
            return
        # memorizza come team corrente
        self._trial_current_team = (team or "").strip()

        tri["states"][row_idx] = int(state)   # 0,1,2,3
        tri["points"][row_idx] = int(points)  # 0 / p1 / p2 / p3
        save_db_atomic(self.db, self.db_path)

        # aggiorna il totale a schermo
        self.page_trial.load_team_points(tri["points"])

    def _is_team_completed(self, team: str) -> bool:
        if not self.db or not team:
            return False
        key = team.lstrip("0") or "0"
        tri = self.db.get("teams", {}).get(key, {}).get("trial", {})
        return bool(tri.get("completed"))
    
    def _migrate_db(self):
        if not self.db:
            return
        teams = self.db.setdefault("teams", {})
        normalized = {}
        for k, v in list(teams.items()):
            key = str(k).lstrip("0") or "0"
            tri = v.setdefault("trial", {})
            # legacy 'scores' → 'points'
            if "scores" in tri and "points" not in tri:
                tri["points"] = list(tri["scores"])
                tri.pop("scores", None)
            tri.setdefault("states", [0]*9)
            tri.setdefault("points", [0]*9)
            # imposta completed se ci sono già dati
            if not tri.get("completed"):
                if any(int(x) > 0 for x in tri["points"]) or any(int(s) > 0 for s in tri["states"]):
                    tri["completed"] = True
            normalized[key] = v
        self.db["teams"] = normalized
        save_db_atomic(self.db, self.db_path)

    # ---- stile ----
    def apply_styles(self):
        self.setStyleSheet("""
    QWidget#root { background:#000; color:#BFFFBF; }
    QTabWidget::pane { border:0; }
    QTabBar::tab {
        background:#2b2b2b; color:#bbb;
        margin-right:4px; padding:14px 0;
        min-height:56px; font: 16pt "Arial";
    }
    QTabBar::tab:selected { background:#1db954; color:#000; }
    QTabBar::tab:disabled { background:#333; color:#777; }

    QLineEdit#eventDate {
        background:#111; color:#e6e6e6; border:2px solid #333;
        padding:6px 10px; font: 12pt "Arial";
    }
    QLineEdit#eventDate[required="true"] { border:2px solid #c44; }

    QWidget#header { background:#000; }
    QLabel#appTitle { color:#39ff14; font: 28pt "Arial Black"; }
    QLabel#byline   { color:#ff4dff; font: 12pt "Arial"; }
    QLineEdit#eventTitle {
        background:#111; color:#e6e6e6; border:2px solid #333;
        padding:10px 14px; font: 22pt "Arial";
    }
    QLineEdit#eventTitle[required="true"] { border:2px solid #c44; }

    QLineEdit#counter { background:#fff; color:#000; border:2px solid #222;
                        font:bold 22pt "Arial"; border-radius:6px; }

    QPushButton#hdrBtn {
        background:#222; color:#ddd; border:1px solid #444;
        padding:4px 10px; border-radius:4px; font: 10pt "Arial";
    }
    QPushButton#hdrBtn:hover { background:#2e2e2e; }

    QLineEdit#scanInput { background:#111; color:#ddd; border:1px solid #444; padding:10px; font:12pt; }
    QLineEdit#metaBox   { background:#111; color:#ddd; border:1px solid #333; padding:10px; font:11pt; }

    QLabel#trialLabel { color:#ddd; font: 11pt "Arial"; }

    /* toggle cards */
    QWidget#card QPushButton#toggleBtn {
        background:#3a3a3a; color:#e6e6e6;
        border:1px solid #4a4a4a; border-radius:8px;
    }
    QWidget#card QPushButton#toggleBtn:checked { background:#1db954; color:#000; }
                           
    /* Attempt buttons: rosso/verde come toggle pieni */
    QPushButton#attemptBtn { border:1px solid #444; border-radius:4px; }
    QPushButton#attemptBtn[role="fail"]        { background:#b22; color:#fff; border:1px solid #444; }
    QPushButton#attemptBtn[role="pass"]        { background:#1db954; color:#000; border:1px solid #444; }
    QPushButton#attemptBtn[role="fail"]:checked{ background:#ff3b30; color:#000; }
    QPushButton#attemptBtn[role="pass"]:checked{ background:#00e060; color:#000; }
    QPushButton#attemptBtn[role="fail"]:disabled:checked { background:#ff3b30; color:#000; }
    QPushButton#attemptBtn[role="pass"]:disabled:checked { background:#00e060; color:#000; }
    QPushButton#trialOkTeamBtn { background:#1db954; color:#000; font:bold 18pt; }
    QPushButton#trialOkTeamBtn:disabled { background:#2b2b2b; color:#777; }

    /* moltiplicatori */
    QDoubleSpinBox { background:#d5ffd5; border:1px solid #0a0; padding:4px 6px; border-radius:4px; font:12pt; }
    QPushButton#pmBtn { background:#1f1f1f; color:#eee; border:1px solid #3a3a3a; border-radius:4px; font:bold 14pt; padding:0; }

    /* SCAN */
    QLabel#scannedLabel { background:#1db954; color:#000; font:36pt "Arial Black"; }
    QPushButton { background:#cfcfcf; color:#000; border:none; border-radius:6px; padding:14px 20px; font:bold 18pt; }
    QPushButton#okBtn { background: white; color: black; font: bold 18pt "Arial"; }
    QPushButton#okBtn:disabled { background: #e9e9e9; color: #9b9b9b; }

    QPushButton#simBtn { background: #fcf4ca; color: black; font: bold 18pt "Arial"; }
    QPushButton#simBtn:checked { background: #ffd400; }
    QPushButton#simBtn:disabled { background: #b5b5b5; color: #7a7a7a; }

    /* Attempt buttons: rosso/verde come toggle pieni (posto in fondo per vincere sul generico) */
    QPushButton#attemptBtn { border:1px solid #444; border-radius:4px; }
    QPushButton#attemptBtn[role="fail"]        { background:#b22; color:#fff; border:1px solid #444; }
    QPushButton#attemptBtn[role="pass"]        { background:#1db954; color:#000; border:1px solid #444; }
    QPushButton#attemptBtn[role="fail"]:checked{ background:#ff3b30; color:#000; }
    QPushButton#attemptBtn[role="pass"]:checked{ background:#00e060; color:#000; }

    QPushButton#finishBtn { padding:16px 24px; }

    /* TRIAL – sidebar sinistra */
    QLabel#trialHint { color:#ff3b30; font: 36pt "Arial"; font-weight: bold; }
    QLineEdit#trialTeamBox, QLineEdit#trialTotalBox {
        background:#ffffff; color:#000000;
        border:2px solid #bbbbbb; border-radius:6px;
        padding:8px 12px; font: 28pt "Arial"; font-weight:600;
    }
    QLineEdit#trialTotalBox:read-only { background:#f5f5f5; }
    QPushButton#trialFinishBtn {
        background:#ff3b30; color:#000; border:2px solid #772222;
        border-radius:8px; font: 20pt "Arial"; font-weight:700; padding:10px 20px;
    }
    QPushButton#trialFinishBtn:disabled { background:#a44; color:#222; }
    
    /* SpinBox: punti & penalità (TRIAL) – più larghi e leggibili */
    QSpinBox {
        background:#ffffff;
        color:#000;
        border:1px solid #444;
        border-radius:6px;
        /* più “rettangolari”: più padding orizzontale e larghezza minima */
        padding:6px 16px;          /* ↑ allarga visivamente */
        min-height: 34px;          /* altezza */
        min-width: 96px;           /* ← LARGHEZZA: ritocco principale */
        font: bold 15pt "Arial";
    }
    QSpinBox::up-button, QSpinBox::down-button {
        width: 24px;               /* frecce un filo più comode */
    }
    
    /* === HEADER (attempts + penalties) === */
    QSpinBox#hdrSpin {
        padding: 4px 8px;     /* meno padding orizzontale */
        min-width: 60px;      /* consente FixedWidth 72 senza tagli */
        min-height: 30px;
        font: bold 14pt "Arial";
    }

    /* nessuno spazio riservato alle frecce */
    QSpinBox#hdrSpin::up-button,
    QSpinBox#hdrSpin::down-button {
        width: 0; height: 0; margin: 0; padding: 0; border: 0;
    }
    /* Campi nome ostacolo: stessi ingombri degli spinbox */
    QLineEdit#obstName {
        background:#ffffff; color:#000;
        border:1px solid #444; border-radius:6px;
        padding:6px 16px;
        min-height:34px;
        font: bold 15pt "Arial";
    }
    /* Attempt buttons: override finale */
    QPushButton#attemptBtn { border:1px solid #444; border-radius:4px; }
    QPushButton#attemptBtn[role="fail"]        { background:#b22; color:#fff; border:1px solid #444; }
    QPushButton#attemptBtn[role="pass"]        { background:#1db954; color:#000; border:1px solid #444; }
    QPushButton#attemptBtn[role="fail"]:checked{ background:#ff3b30; color:#000; }
    QPushButton#attemptBtn[role="pass"]:checked{ background:#00e060; color:#000; }
    /* --- DISABLED (righe > N in TRIAL) --- */
    QLineEdit#obstName:disabled { background:#222; color:#777; }
    QLabel:disabled            { color:#777; }
    QSpinBox:disabled          { background:#222; color:#777; border:1px solid #333; }

    /* Attempt: quando disabilitati devono sembrare spenti */
    QPushButton#attemptBtn[role="fail"]:disabled,
    QPushButton#attemptBtn[role="pass"]:disabled {
        background:#333; color:#666; border:1px solid #333;
    }
    /* Mantieni il colore quando il tentativo selezionato è disabilitato */
    QPushButton#attemptBtn[role="fail"]:disabled:checked  { background:#ff3b30; border:1px solid #b2241b; color:#fff; }
    QPushButton#attemptBtn[role="pass"]:disabled:checked  { background:#1db954; border:1px solid #128a3e; color:#000; }

    /* Evidenzia anche quando è abilitato e selezionato */
    QPushButton#attemptBtn[role="fail"]:checked  { background:#ff3b30; border:1px solid #b2241b; color:#fff; }
    QPushButton#attemptBtn[role="pass"]:checked  { background:#1db954; border:1px solid #128a3e; color:#000; }
    QPushButton#attemptBtn[role="fail"]:disabled:checked { background:#ff3b30; border:1px solid #b2241b; color:#fff; }
    QPushButton#attemptBtn[role="pass"]:disabled:checked { background:#1db954; border:1px solid #128a3e; color:#000; }

    /* RANKING */
    QLabel#rkHdr { color:#e6e6e6; font: 18pt "Arial Black"; }
    QListWidget#rkList {
        background:#111; color:#ffffff; border:1px solid #333;
        font: 24pt "Arial"; /* grande e chiaro */
    }
    QListWidget#rkList::item { padding: 10px 6px; }
    QLabel#rkHdr:hover { color:#ffffff; }
    QLabel#rkHdr[active="true"] { color:#1db954; }  /* verde quando autoscorre */
        """)
        
    def showEvent(self, e):
        super().showEvent(e)

    def resizeEvent(self, e):
        super().resizeEvent(e)
        bar = self.tabs.tabBar()
        if not bar: 
            return
        w = self.tabs.width()
        n = max(1, bar.count())
        tab_w = max(160, int((w - 8*(n-1)) / n))  # 4 tab = 25% circa
        bar.setStyleSheet(f"QTabBar::tab {{ width: {tab_w}px; }}")

    # --- helper per PRINT/EXPORT: costruisce le colonne del ranking ---
    def _build_ranking_columns(self):
        """Ritorna: [(titolo_colonna, [(team, valore), ...]), ...] per PDF/print.
        Colonne singole = valori grezzi; solo FinalScore applica i pesi."""
        if not getattr(self, "db", None):
            return []

        crit  = self.db.get("criteria", {}) or {}
        teams = self.db.get("teams", {}) or {}

        # pesi SOLO per FinalScore (sezione disabilitata => peso 0)
        def _w(sec):
            cfg = crit.get(sec, {}) or {}
            return float(cfg.get("weight", 1.0)) if cfg.get("enabled", False) else 0.0

        w_acc   = _w("accuracy")
        w_cap   = _w("cap")
        w_spd   = _w("speed")
        w_reg   = _w("regularity")
        w_trial = _w("trial")
        n_trial = int((crit.get("trial", {}) or {}).get("n", 9))

        # utilità: penalità Regularity (come in UI)
        def _hhmmss_to_sec(s):
            try:
                s = str(s).strip()
                if len(s) != 6 or not s.isdigit():
                    return None
                return int(s[:2]) * 3600 + int(s[2:4]) * 60 + int(s[4:6])
            except Exception:
                return None

        def _reg_pen(meta: dict) -> int:
            if not meta:
                return 0
            for k in ("P_REG", "PReg", "PRegularity", "REG_PEN", "pen_reg"):
                if k in meta:
                    try:
                        return int(meta[k])
                    except Exception:
                        pass
            start = _hhmmss_to_sec(meta.get("ora_P"))
            end   = _hhmmss_to_sec(meta.get("ora_A"))
            try:
                km = float(meta.get("km_RB", 0)) / 10.0
            except Exception:
                km = 0.0
            avg_kmh = float((crit.get("regularity", {}) or {}).get("avg_kmh", 0.0)) or (float(meta.get("AVG", 0)) / 10.0)
            if start is None or end is None or km <= 0 or avg_kmh <= 0:
                return 0
            actual = end - start
            if actual < 0:
                actual += 24 * 3600
            expected = int(round(3600.0 * km / avg_kmh))
            delta = actual - expected
            late  = delta if delta > 0 else 0
            early = -delta if delta < 0 else 0
            return int(early + max(late - 59, 0))  # perdono 59" di ritardo

        # costruzione righe singole (grezze)
        acc_rows, cap_rows, spd_rows, reg_rows, trial_rows, total_rows = [], [], [], [], [], []

        for team, rec in teams.items():
            meta = rec.get("meta", {}) or {}
            tri  = rec.get("trial", {}) or {}

            pts = int(meta.get("punti_T", 0) or 0)
            cap = int(meta.get("P_CAP",   0) or 0)
            spd = int(meta.get("PSpeed",  0) or 0)
            acc = pts + int(meta.get("P_skip", 0) or 0) + int(meta.get("P_extra", 0) or 0)
            reg = _reg_pen(meta)

            pts_list = [int(x) for x in (tri.get("points", []) or [])][:n_trial]
            tri_tot  = sum(pts_list) if pts_list else 0

            acc_rows.append((team, acc))
            cap_rows.append((team, cap))
            spd_rows.append((team, spd))
            reg_rows.append((team, reg))
            trial_rows.append((team, tri_tot))

            total = w_acc * acc + w_cap * cap + w_spd * spd + w_reg * reg - w_trial * tri_tot
            total_rows.append((team, round(total, 2)))

        # ordinamenti: penalità crescenti; Trial decrescente; FinalScore crescente
        acc_rows.sort(key=lambda x: x[1])
        cap_rows.sort(key=lambda x: x[1])
        spd_rows.sort(key=lambda x: x[1])
        reg_rows.sort(key=lambda x: x[1])
        trial_rows.sort(key=lambda x: x[1], reverse=True)
        total_rows.sort(key=lambda x: x[1])

        return [
            ("FinalScore",    total_rows),
            ("Accuracy",   acc_rows),
            ("CAP",        cap_rows),
            ("Speed",      spd_rows),
            ("Regularity", reg_rows),
            ("Trial",      trial_rows),
        ]

    def print_criteria_pdf(self, printer, painter=None):
        from PySide6.QtGui import QTextDocument, QPainter
        from PySide6.QtCore import QRectF, QSizeF
        import html as _html
        import re as _re
        import textwrap as _tw

        title = ""
        allowed = ""
        try:
            if getattr(self, "db", None) and isinstance(self.db, dict):
                title = str(self.db.get("event_title", "") or "")
                allowed = str((self.db.get("scan_rules", {}) or {}).get("allowed_date", "") or
                            self.db.get("event_date", "") or "")
        except Exception:
            pass

        if not title:
            title = (self.header.event_title.text() or "").strip() or "Evento"

        if not allowed:
            date_txt = (self.header.event_date.text() or "").strip()
            allowed = _re.sub(r"[^0-9]", "", date_txt)

        pretty = allowed
        if pretty.isdigit() and len(pretty) == 6:
            pretty = f"{pretty[:2]}/{pretty[2:4]}/{pretty[4:]}"

        txt = self._criteria_summary_text(title, pretty)

        out_lines = []
        for ln in txt.splitlines():
            if ln.strip().startswith("Nomi:") or ln.startswith("  Nomi:"):
                prefix = "  Nomi: "
                rest = ln[len(prefix):] if ln.startswith(prefix) else ln.split("Nomi:", 1)[-1].strip()
                chunks = _tw.wrap(rest, width=90)
                if chunks:
                    out_lines.append(prefix + chunks[0])
                    for c in chunks[1:]:
                        out_lines.append("        " + c)
                else:
                    out_lines.append(ln)
            else:
                out_lines.append(ln)
        txt = "\n".join(out_lines)

        html = (
            "<div style='font-size:14pt;'>"
            "<pre style='font-family:Courier New; font-size:13pt; line-height:1.25;'>"
            f"{_html.escape(txt)}"
            "</pre></div>"
        )

        doc = QTextDocument()
        doc.setHtml(html)

        from PySide6.QtGui import QPageLayout, QPainter
        from PySide6.QtCore import QRectF

        rect_pt = printer.pageLayout().paintRect(QPageLayout.Point)

        # 1 punto = 1/72", ma il painter del printer lavora in "device units" (dpi)
        dpi = printer.resolution()
        scale = dpi / 72.0

        doc.setPageSize(rect_pt.size())

        p = painter or QPainter(printer)
        own = painter is None

        p.save()
        p.scale(scale, scale)  # <-- questa è la chiave
        p.translate(rect_pt.left(), rect_pt.top())
        doc.drawContents(p, QRectF(0, 0, rect_pt.width(), rect_pt.height()))
        p.restore()

        if own:
            p.end()

    def print_pdf_dialog(self):
        # Dialog con checkbox: CRITERIA / RANKING
        from PySide6.QtWidgets import (
            QDialog, QVBoxLayout, QLabel, QCheckBox,
            QDialogButtonBox, QFileDialog, QMessageBox
        )
        from PySide6.QtPrintSupport import QPrinter
        from PySide6.QtGui import QPageLayout, QPainter
        import os

        has_db = bool(getattr(self, "db", None)) and isinstance(self.db, dict)
        has_rank = has_db and bool((self.db.get("teams", {}) or {}))

        dlg = QDialog(self)
        dlg.setWindowTitle("Print")
        lay = QVBoxLayout(dlg)

        lay.addWidget(QLabel("Seleziona cosa stampare:"))

        ck_crit = QCheckBox("CRITERIA")
        ck_rank = QCheckBox("RANKING")

        ck_crit.setChecked(True)
        ck_rank.setChecked(True if has_rank else False)
        ck_rank.setEnabled(bool(has_rank))

        lay.addWidget(ck_crit)
        lay.addWidget(ck_rank)

        bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        bb.accepted.connect(dlg.accept)
        bb.rejected.connect(dlg.reject)
        lay.addWidget(bb)

        if dlg.exec() != QDialog.Accepted:
            return

        do_crit = ck_crit.isChecked()
        do_rank = ck_rank.isChecked()

        if not (do_crit or do_rank):
            QMessageBox.warning(self, "Print", "Seleziona almeno una voce.")
            return

        if do_rank and not has_rank:
            QMessageBox.warning(self, "Print", "RANKING non disponibile (nessun TEAM nel DB).")
            do_rank = False
            if not do_crit:
                return

        # nome file suggerito
        base_dir = getattr(self, "db_dir", "") or os.path.expanduser("~")
        ev_title = ""
        try:
            ev_title = (self.db.get("event_title", "") if has_db else "") or ""
        except Exception:
            ev_title = ""
        if not ev_title:
            ev_title = (self.header.event_title.text() or "").strip() or "evento"

        try:
            base = slugify(ev_title) or "evento"
        except Exception:
            base = "evento"

        suffix = "criteria" if (do_crit and not do_rank) else ("ranking" if (do_rank and not do_crit) else "criteria_ranking")
        default_name = f"{base}_{suffix}.pdf"

        path, _ = QFileDialog.getSaveFileName(
            self,
            "Salva PDF",
            os.path.join(base_dir, default_name),
            "PDF (*.pdf)"
        )
        if not path:
            return
        if not path.lower().endswith(".pdf"):
            path += ".pdf"

        pr = QPrinter(QPrinter.HighResolution)
        pr.setOutputFormat(QPrinter.PdfFormat)
        pr.setOutputFileName(path)
        pr.setResolution(300)
        pr.setFullPage(False)

        # se c'è RANKING, landscape (così resta come ora)
        pr.setPageOrientation(QPageLayout.Landscape if do_rank else QPageLayout.Portrait)

        from PySide6.QtGui import QPainter

        p = QPainter(pr)

        if do_crit:
            self.print_criteria_pdf(pr, painter=p)

        if do_rank:
            if do_crit:
                pr.newPage()
            self.print_ranking_pdf(pr, painter=p)

        p.end()

    def print_ranking_pdf_dialog(self):
        from PySide6.QtWidgets import QFileDialog
        from PySide6.QtPrintSupport import QPrinter
        from PySide6.QtGui import QPageLayout

        default_dir = getattr(self, "db_dir", "") or ""
        path, _ = QFileDialog.getSaveFileName(self, "Salva ranking come PDF",
                                            default_dir, "PDF (*.pdf)")
        if not path:
            return
        if not path.lower().endswith(".pdf"):
            path += ".pdf"

        pr = QPrinter(QPrinter.HighResolution)
        pr.setOutputFormat(QPrinter.PdfFormat)
        pr.setOutputFileName(path)                 # <— importante!
        pr.setPageOrientation(QPageLayout.Landscape)
        pr.setResolution(300)
        pr.setFullPage(False)

        self.print_ranking_pdf(pr)

    def print_ranking_pdf(self, printer, painter=None):
        from PySide6.QtGui import QPainter, QFont, QColor
        from PySide6.QtCore import Qt, QRect

        if not getattr(self, "db", None):
            return
        cols = self._build_ranking_columns()
        if not cols:
            return
        rank_idx = next((i for i,(n,_) in enumerate(cols) if n.upper()=="FinalScore"), -1)

        p = painter or QPainter(printer)
        own = painter is None
        if not p.isActive():
            return
        p.setRenderHints(QPainter.TextAntialiasing | QPainter.Antialiasing)

        dpi = printer.resolution()
        r   = printer.pageLayout().paintRectPixels(dpi)  # ← tutto in pixel
        # margini più generosi (evita tagli titolo/header)
        Mx = int(0.75 * dpi)   # 0.75" sx/dx
        My = int(0.85 * dpi)   # 0.85" top/bottom
        x0, y0 = r.left() + Mx, r.top() + My
        W,  H  = r.width() - 2*Mx, r.height() - 2*My

        # font un po' più piccoli
        title_f = QFont("Arial Black", 18)
        hdr_f   = QFont("Arial Black", 15)
        row_f   = QFont("Arial", 12)

        # — Titolo —
        p.setFont(title_f)
        fm_t = p.fontMetrics()
        title_text = self.db.get("event_title", "Ranking")
        title_h = fm_t.height() + int(0.25 * dpi)  # aria sopra/sotto
        p.setPen(QColor("#000"))
        p.drawText(QRect(x0, y0, W, title_h),
                Qt.AlignHCenter | Qt.AlignVCenter | Qt.TextSingleLine,
                title_text)
        top_y = y0 + title_h + int(0.25 * dpi)

        # — Geometria colonne —
        N = len(cols)
        COL_GAP = max(int(0.04 * W), int(0.35 * dpi))       # spazio tra colonne
        col_w   = (W - (N - 1) * COL_GAP) // max(1, N)

        # dentro colonna: [pos]  gap  [team]  gap  [val]
        POS_W        = int(col_w * 0.22)
        POS_TEAM_GAP = int(col_w * 0.12)
        TEAM_W       = int(col_w * 0.20)
        MID_GAP      = int(col_w * 0.18)
        VAL_W        = col_w - (POS_W + POS_TEAM_GAP + TEAM_W + MID_GAP)

        # — Header/righe —
        p.setFont(hdr_f); fm_h = p.fontMetrics()
        header_h = fm_h.height() + int(0.10 * dpi)

        p.setFont(row_f); fm_r = p.fontMetrics()
        row_h = fm_r.height() + int(0.14 * dpi)
        first_row_y = top_y + header_h + int(0.10 * dpi)

        # assicurati che il campo valore (VAL_W) contenga fino a "−9999,9"
        sample_txt = "−9999,9"
        req_val_w = fm_r.horizontalAdvance(sample_txt) + int(0.10 * dpi)
        if VAL_W < req_val_w:
            VAL_W = req_val_w
            # ricomponi la larghezza di colonna dopo l'aumento del valore
            COL_W = POS_W + POS_TEAM_GAP + TEAM_W + MID_GAP + VAL_W

        # paginazione
        usable_bottom = y0 + H
        rows_per_pg = max(1, (usable_bottom - first_row_y) // row_h)
        total_rows  = max((len(rws) for _, rws in cols), default=0)
        pages       = max(1, (total_rows + rows_per_pg - 1) // rows_per_pg)

        team_col = QColor("#1db954")
        val_col  = QColor("#ff3b30")
        idx_col  = QColor("#000")

        for pg in range(pages):
            if pg > 0:
                printer.newPage()
                p.setFont(title_f); p.setPen(idx_col)
                p.drawText(QRect(x0, y0, W, title_h),
                        Qt.AlignHCenter | Qt.AlignVCenter | Qt.TextSingleLine,
                        title_text)

            # header colonne
            p.setFont(hdr_f); p.setPen(idx_col)
            for ci, (name, _) in enumerate(cols):
                cx = x0 + ci * (col_w + COL_GAP)
                p.drawText(QRect(cx, top_y, col_w, header_h),
                        Qt.AlignHCenter | Qt.AlignVCenter | Qt.TextSingleLine,
                        name)

            # righe (slice della pagina)
            p.setFont(row_f)
            start = pg * rows_per_pg
            end   = min(start + rows_per_pg, total_rows)
            for ci, (_, rows) in enumerate(cols):
                cx = x0 + ci * (col_w + COL_GAP)
                yy = first_row_y
                for idx, (team, val) in enumerate(rows[start:end], start=start + 1):
                    # posizione
                    p.setPen(idx_col)
                    p.drawText(QRect(cx, yy, POS_W, row_h),
                            Qt.AlignRight | Qt.AlignVCenter | Qt.TextSingleLine,
                            str(idx))
                    # team
                    p.setPen(team_col)
                    p.drawText(QRect(cx + POS_W + POS_TEAM_GAP, yy, TEAM_W, row_h),
                            Qt.AlignLeft | Qt.AlignVCenter | Qt.TextSingleLine,
                            str(team))
                    # valore
                    p.setPen(val_col)
                    p.drawText(QRect(cx + POS_W + POS_TEAM_GAP + TEAM_W + MID_GAP, yy, VAL_W, row_h),
                            Qt.AlignRight | Qt.AlignVCenter | Qt.TextSingleLine,
                            (f"{val:.1f}".replace(".", ",") if ci==rank_idx else str(val)))
                    yy += row_h

        if own:
            p.end()

# ----------------- main -----------------
def main():
    app = QApplication(sys.argv)
    w = MainWindow()
    w.showMaximized()        # << invece di show() + singleShot
    sys.exit(app.exec())

if __name__ == "__main__":
    main()
